Compare commits

...

56 Commits

Author SHA1 Message Date
Aria Moradi c6e57e2700 fix typo
Publish / Validate Gradle Wrapper (push) Successful in 11s
Publish / Build FatJar (push) Failing after 18s
2021-03-23 05:58:45 +04:30
Aria Moradi c5f467ce3d add no-webUI jar 2021-03-23 05:49:56 +04:30
Aria Moradi 85ec2ed367 drawer hide on click outside of it 2021-03-23 04:28:23 +04:30
Aria Moradi bf908c4d17 chapter prev/next UI+Backend 2021-03-23 03:50:55 +04:30
Aria Moradi f41c5c9428 bump version
Publish / Validate Gradle Wrapper (push) Successful in 11s
Publish / Build FatJar (push) Failing after 16s
2021-03-19 14:55:48 +03:30
Aria Moradi 04837983fa reader ui changes 2021-03-19 14:52:20 +03:30
Aria Moradi 5d484b012c new layout for manga page for >md 2021-03-18 22:55:17 +03:30
Aria Moradi 436a8d0585 improvments on the reader 2021-03-18 21:46:24 +03:30
Aria Moradi 28cc0a6f84 Merge branch 'master' of github.com:AriaMoradi/Tachidesk 2021-03-17 19:22:44 +03:30
Aria Moradi 26cc2f2c96 MangaDetails component improved drastically 2021-03-17 19:17:03 +03:30
Aria Moradi 149107e749 fix material error 2021-03-16 23:42:51 +03:30
Aria Moradi a74936c5f5 Update README.md 2021-03-16 23:24:14 +03:30
Aria Moradi ff8c8913d4 Update README.md 2021-03-16 23:14:36 +03:30
Aria Moradi 83426e1302 Update README.md 2021-03-16 23:13:44 +03:30
Aria Moradi 9cd93d467c bump version
Publish / Validate Gradle Wrapper (push) Successful in 11s
Publish / Build FatJar (push) Failing after 17s
2021-03-16 16:15:20 +03:30
Aria Moradi 257f8a5a27 fix extensions not showing the all pesudo-language 2021-03-16 16:10:06 +03:30
Aria Moradi 79bab08cae improvements 2021-03-16 16:04:29 +03:30
Aria Moradi 4e699e4f5a update dex2jar 2021-03-16 15:44:50 +03:30
Aria Moradi 1128f40bac closes #32 2021-03-14 23:57:33 +03:30
Aria Moradi 53ef836326 fix windows path 2021-03-14 20:28:23 +03:30
Aria Moradi b8df0e89e5 Don't show installed if nothing is installed 2021-03-14 14:09:31 +03:30
Aria Moradi 472bfec6bf improve docs 2021-03-14 01:26:52 +03:30
Aria Moradi c1b86cedd2 move getAndroid.sh 2021-03-14 01:02:43 +03:30
Aria Moradi 428c65f075 Enforce more limits on the issue format. 2021-03-13 22:59:37 +03:30
Aria Moradi 92ed48f7f6 bump version to v0.2.4
Publish / Validate Gradle Wrapper (push) Successful in 12s
Publish / Build FatJar (push) Failing after 15s
2021-03-13 11:08:39 +03:30
Aria Moradi 13e84bc492 Maskable icons 2021-03-13 11:06:22 +03:30
Aria Moradi 0ef86c34b7 server configuration fam 2021-03-11 14:43:29 +03:30
Aria Moradi 7e1a4259d7 fix langs not showing correctly 2021-03-09 18:05:34 +03:30
Aria Moradi c842c51fb6 section sources by lang 2021-03-09 16:44:09 +03:30
Aria Moradi 6f2f228e08 section extension languages 2021-03-08 21:04:42 +03:30
Aria Moradi c78eaa8b96 add issue closer 2021-03-08 13:47:58 +03:30
Aria Moradi f9606526d2 add issue closer 2021-03-08 13:39:25 +03:30
Aria Moradi fe4cc9ea2c add issue closer 2021-03-08 13:32:17 +03:30
Aria Moradi 54d0c05fcc add issue closer 2021-03-08 13:31:03 +03:30
Aria Moradi 2f7df73a37 add issue closer 2021-03-08 13:22:44 +03:30
Aria Moradi cf19f3626b improve text 2021-03-08 13:01:23 +03:30
Aria Moradi ff2da5e59b issue template 2021-03-08 12:57:12 +03:30
Aria Moradi e03922e518 fix PWA icons 2021-03-07 23:08:30 +03:30
Aria Moradi 893fba5b8c fix image urls 2021-03-07 22:35:27 +03:30
Aria Moradi c1786f8e24 migrate to axios, front-end part of configurable ServerAddress 2021-03-07 22:25:29 +03:30
Aria Moradi a59f974537 fix #25 2021-03-07 22:12:38 +03:30
Aria Moradi 7157e07328 better messages, axios client 2021-03-07 16:27:13 +03:30
Aria Moradi 954084bd82 Merge branch 'master' of github.com:AriaMoradi/Tachidesk 2021-03-07 10:51:24 +03:30
Aria Moradi 0915ba40f6 🤌 Tachidesk's logo! 2021-02-25 21:54:49 +03:30
Aria Moradi de30d55bcf darkTheme in localStorage 2021-02-25 14:38:16 +03:30
Aria Moradi af1c34fba5 v0.2.3
Publish / Validate Gradle Wrapper (push) Successful in 12s
Publish / Build FatJar (push) Failing after 16s
2021-02-24 12:27:28 +03:30
Aria Moradi 7b7d93786f Merge branch 'master' of github.com:AriaMoradi/Tachidesk 2021-02-24 12:09:39 +03:30
Aria Moradi 7c1c504482 new icon, fix headless systems crashing 2021-02-24 11:55:43 +03:30
Aria Moradi 33b22fcab6 Update README.md 2021-02-22 14:54:04 +03:30
Aria Moradi ab0566dcba Update README.md 2021-02-22 14:51:39 +03:30
Aria Moradi c4f2cc7189 Update README.md 2021-02-22 14:49:17 +03:30
Aria Moradi 4626d99590 Update README.md 2021-02-22 14:48:17 +03:30
Aria Moradi 6465ca8a19 Update README.md 2021-02-22 01:29:55 +03:30
Aria Moradi 15b9d151df Update README.md 2021-02-22 01:28:13 +03:30
Aria Moradi dd1b6c86cd Update README.md 2021-02-22 01:23:44 +03:30
Aria Moradi 9613cda79a new icons by @as280093 2021-02-21 23:37:11 +03:30
106 changed files with 2167 additions and 669 deletions
+43
View File
@@ -0,0 +1,43 @@
---
name: "🐞 Bug report"
title: "[Bug] <short description>"
about: "Report a bug"
labels: "bug"
---
**PLEASE READ THIS**
I acknowledge that:
- I have updated to the latest version of the app.
- I have tried the troubleshooting guide described in `README.md`
- If this is a request for adding/changing an extension it should be brought up to Tachiyomi: https://github.com/tachiyomiorg/tachiyomi-extensions/issues/new/choose
- If this is an issue with some extension not working properly, It does work inside Tachiyomi as intended.
- I have searched the existing issues and this is a new ticket **NOT** a duplicate or related to another open issue
- I will fill out the title and the information in this template
Note that the issue will be automatically closed if you do not fill out the title or requested information.
**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**
---
## Device information
- Tachidesk version: (Example: v0.2.3-r255-win32)
- Server Operating System: (Example: Ubuntu 20.04)
- Server JVM version: bundled with win32 or (Example: Java 8 Update 281 or OpenJDK 8u281)
- Client Operating System: <usually the same as above Server Operating System>
- Client Web Browser: (Example: Google Chrome 89.0.4389.82)
## Steps to reproduce
1. First Step
2. Second Step
### Expected behavior
Describe what should have happened. Remove this line after you are done.
### Actual behavior
Describe what happens instead. Remove this line after you are done.
## Other details
Describe additional details If necessary. Remove this line after you are done.
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+29
View File
@@ -0,0 +1,29 @@
---
name: "🌟 Feature request"
title: "[Feature Request] <short description>"
about: "Suggest a feature to improve the project"
labels: "enhancement"
---
**PLEASE READ THIS**
I acknowledge that:
- I have updated to the latest version of the app.
- I have tried the troubleshooting guide described in `README.md`
- If this is a request for adding/changing an extension it should be brought up to Tachiyomi: https://github.com/tachiyomiorg/tachiyomi-extensions/issues/new/choose
- If this is an issue with some extension not working properly, It does work in Tachiyomi application as intended.
- I have searched the existing issues and this is a new ticket **NOT** a duplicate or related to another open issue
- I will fill out the title and the information in this template
Note that the issue will be automatically closed if you do not fill out the title or requested information.
**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**
---
## What feature should be added to Tachidesk?
Explain What the feature is and how it should work in detail. Remove this line after you are done.
## Why/Project's Benefit/Existing Problem
Explain why this should be added. Remove this line after you are done.
+37
View File
@@ -0,0 +1,37 @@
name: Issue closer
on:
issues:
types: [opened, edited, reopened]
jobs:
autoclose:
runs-on: ubuntu-latest
steps:
- name: Autoclose issues
uses: arkon/issue-closer-action@v3.0
with:
repo-token: ${{ github.token }}
rules: |
[
{
"type": "title",
"regex": ".*<short description>*",
"message": "You did not fill out the description in the title"
},
{
"type": "body",
"regex": ".*DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT.*",
"message": "The acknowledgment section was not removed"
},
{
"type": "body",
"regex": "(Tachidesk version|Server Operating System|Server JVM version|Client Operating System|Client Web Browser):.*(\\(Example:|<usually).*",
"message": "The requested information was not filled out"
},
{
"type": "body",
"regex": ".*Remove this line after you are done.*",
"message": "The lines requesting to be removed were not removed."
}
]
+15
View File
@@ -58,6 +58,21 @@ jobs:
**/react/node_modules **/react/node_modules
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
- name: Build no-webUI Jar
uses: eskatos/gradle-command-action@v1
with:
build-root-directory: master
wrapper-directory: master
arguments: :server:shadowJar -x :webUI:copyBuild --stacktrace
wrapper-cache-enabled: true
dependencies-cache-enabled: true
configuration-cache-enabled: true
- name: Rename the no-webUI Jar
run: |
cd master/server/build
mv Tachidesk-*.jar $(ls *.jar | sed 's/\.jar/-no-webUI\.jar/g')
- name: Build Jar and launch4j - name: Build Jar and launch4j
uses: eskatos/gradle-command-action@v1 uses: eskatos/gradle-command-action@v1
with: with:
+1 -1
View File
@@ -1,4 +1,4 @@
dependencies { dependencies {
// Config API // Config API, moved to the global build.gradle
// implementation("com.typesafe:config:1.4.0") // implementation("com.typesafe:config:1.4.0")
} }
@@ -4,54 +4,55 @@ import com.typesafe.config.Config
import com.typesafe.config.ConfigFactory import com.typesafe.config.ConfigFactory
import com.typesafe.config.ConfigRenderOptions import com.typesafe.config.ConfigRenderOptions
import mu.KotlinLogging import mu.KotlinLogging
import net.harawata.appdirs.AppDirsFactory
import java.io.File import java.io.File
/** /**
* Manages app config. * Manages app config.
*/ */
open class ConfigManager { open class ConfigManager {
private val generatedModules private val dataRoot by lazy { AppDirsFactory.getInstance().getUserDataDir("Tachidesk", null, null)!! }
= mutableMapOf<Class<out ConfigModule>, ConfigModule>()
private val generatedModules = mutableMapOf<Class<out ConfigModule>, ConfigModule>()
val config by lazy { loadConfigs() } val config by lazy { loadConfigs() }
//Public read-only view of modules //Public read-only view of modules
val loadedModules: Map<Class<out ConfigModule>, ConfigModule> val loadedModules: Map<Class<out ConfigModule>, ConfigModule>
get() = generatedModules get() = generatedModules
open val configFolder: String open val appConfigFile: String = "$dataRoot/server.conf"
get() = System.getProperty("compat-configdirs") ?: "tachiserver-data/config"
val logger = KotlinLogging.logger {} val logger = KotlinLogging.logger {}
/** /**
* Get a config module * Get a config module
*/ */
inline fun <reified T : ConfigModule> module(): T inline fun <reified T : ConfigModule> module(): T = loadedModules[T::class.java] as T
= loadedModules[T::class.java] as T
/** /**
* Get a config module (Java API) * Get a config module (Java API)
*/ */
fun <T : ConfigModule> module(type: Class<T>): T fun <T : ConfigModule> module(type: Class<T>): T = loadedModules[type] as T
= loadedModules[type] as T
/** /**
* Load configs * Load configs
*/ */
fun loadConfigs(): Config { fun loadConfigs(): Config {
val configs = mutableListOf<Config>() //Load reference configs
val compatConfig = ConfigFactory.parseResources("compat-reference.conf")
val serverConfig = ConfigFactory.parseResources("server-reference.conf")
//Load reference config //Load user config
configs += ConfigFactory.parseResources("reference.conf") val userConfig =
File(appConfigFile).let{
ConfigFactory.parseFile(it)
}
//Load custom configs from dir val config = ConfigFactory.empty()
File(configFolder).listFiles()?.map { .withFallback(userConfig)
ConfigFactory.parseFile(it) .withFallback(compatConfig)
}?.filterNotNull()?.forEach { .withFallback(serverConfig)
configs += it.withFallback(configs.last()) .resolve()
}
val config = configs.last().resolve()
logger.debug { logger.debug {
"Loaded config:\n" + config.root().render(ConfigRenderOptions.concise().setFormatted(true)) "Loaded config:\n" + config.root().render(ConfigRenderOptions.concise().setFormatted(true))
@@ -1,35 +0,0 @@
package xyz.nulldev.ts.config
import com.typesafe.config.Config
import java.io.File
class ServerConfig(config: Config) : ConfigModule(config) {
val ip = config.getString("ip")
val port = config.getInt("port")
val allowConfigChanges = config.getBoolean("allowConfigChanges")
val enableWebUi = config.getBoolean("enableWebUi")
val useOldWebUi = config.getBoolean("useOldWebUi")
val prettyPrintApi = config.getBoolean("prettyPrintApi")
// TODO Apply to operation IDs
val disabledApiEndpoints = config.getStringList("disabledApiEndpoints").map(String::toLowerCase)
val enabledApiEndpoints = config.getStringList("enabledApiEndpoints").map(String::toLowerCase)
val httpInitializedPrintMessage = config.getString("httpInitializedPrintMessage")
val useExternalStaticFiles = config.getBoolean("useExternalStaticFiles")
val externalStaticFilesFolder = config.getString("externalStaticFilesFolder")
val rootDir = registerFile(config.getString("rootDir"))
val patchesDir = registerFile(config.getString("patchesDir"))
fun registerFile(file: String): File {
return File(file).apply {
mkdirs()
}
}
companion object {
fun register(config: Config)
= ServerConfig(config.getConfig("ts.server"))
}
}
@@ -1,4 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# foolproof against running from AndroidCompat dir instead of running from project root
if [ "$(basename $(pwd))" = "AndroidCompat" ]; then
cd ..
fi
echo "Getting required Android.jar..." echo "Getting required Android.jar..."
rm -rf "tmp" rm -rf "tmp"
mkdir -p "tmp" mkdir -p "tmp"
@@ -1,6 +1,3 @@
# Server ip and port bindings
ts.server.ip = 0.0.0.0
ts.server.port = 4567
# Allow/disallow preference changes (useful for demos) # Allow/disallow preference changes (useful for demos)
ts.server.allowConfigChanges = true ts.server.allowConfigChanges = true
+51 -26
View File
@@ -1,3 +1,5 @@
![image](https://github.com/AriaMoradi/Tachidesk/raw/master/server/src/main/resources/icon/faviconlogo.png)
# Tachidesk # Tachidesk
A free and open source manga reader that runs extensions built for [Tachiyomi](https://tachiyomi.org/). A free and open source manga reader that runs extensions built for [Tachiyomi](https://tachiyomi.org/).
@@ -5,28 +7,66 @@ Tachidesk is as multi-platform as you can get. Any platform that runs java and/o
Ability to read and write Tachiyomi compatible backups and syncing is a planned feature. Ability to read and write Tachiyomi compatible backups and syncing is a planned feature.
## How do I run the app? ## Is this application usable? Should I test it?
#### Prerequisites Here is a list of current features:
You should have The Java Runtime Environment(JRE) 8 or newer (if you're not planning to use the Windows specific build) and a modern browser installed. Also an internet connection is required as almost everything this app does is downloading stuff.
#### Download the app - Installing and executing Tachiyomi's Extensions, So you'll get the same sources.
Download the latest jar or windows(win32) release from [the releases section](https://github.com/AriaMoradi/Tachidesk/releases). - A library to save your mangas and categories to put them into.
- Searching and browsing installed sources.
- A minimal chapter reader.
- Ability to download Mangas for offline read(This partially works)
**Note:** Keep in mind that Tachidesk is alpha software and can break rarely and/or with each update, so you may have to delete your data to fix it. See [General troubleshooting](#general-troubleshooting) and [Support and help](#support-and-help) if it happens.
Anyways, for more info checkout [finished milestone #1](https://github.com/AriaMoradi/Tachidesk/issues/2) and [milestone #2](https://github.com/AriaMoradi/Tachidesk/projects/1) to see what's implemented in more detail.
## Downloading and Running the app
### All Operating Systems
You should have The Java Runtime Environment(JRE) 8 or newer and a modern browser installed. Also an internet connection is required as almost everything this app does is downloading stuff.
Download the latest jar release from [the releases section](https://github.com/AriaMoradi/Tachidesk/releases).
#### Running pre-built jar packages
Double click on the jar file or run `java -jar Tachidesk-vX.Y.Z-rxxx.jar` from a Terminal/Command Prompt window to run the app which will open a new browser window automatically. Also the System Tray Icon is your friend if you need to open the browser window again or close Tachidesk. Double click on the jar file or run `java -jar Tachidesk-vX.Y.Z-rxxx.jar` from a Terminal/Command Prompt window to run the app which will open a new browser window automatically. Also the System Tray Icon is your friend if you need to open the browser window again or close Tachidesk.
#### Running pre-built Windows packages ### Windows
Windows specific builds have java bundled inside them, so you don't have to install java to use it. Unzip `Tachidesk-vX.Y.Z-rxxx-win32.zip` and run `server.exe`, the rest will work like the jar release. Download the latest win32 release from [the releases section](https://github.com/AriaMoradi/Tachidesk/releases).
#### Running on Docker The Windows specific build has java bundled inside, so you don't have to install java to use it. Unzip `Tachidesk-vX.Y.Z-rxxx-win32.zip` and run `server.exe`. The rest works like the previous section.
### Arch Linux
You can install Tachidesk from the AUR
```
yay -S tachidesk
```
### Docker
Check [arbuilder's repo](https://github.com/arbuilder/Tachidesk-docker) out for more details and the dockerfile. Check [arbuilder's repo](https://github.com/arbuilder/Tachidesk-docker) out for more details and the dockerfile.
## General troubleshooting
If the app breaks try deleting the directory below and re-running the app (**This will delete all your data!**) and if the problem persists open an issue.
On Mac OS X : `/Users/<Account>/Library/Application Support/Tachidesk`
On Windows XP : `C:\Documents and Settings\<Account>\Application Data\Local Settings\Tachidesk`
On Windows 7 and later : `C:\Users\<Account>\AppData\Local\Tachidesk`
On Unix/Linux : `/home/<account>/.local/share/Tachidesk`
## Support and help
Join Tachidesk's [discord server](https://discord.gg/wgPyb7hE5d) to hang out with the community and receive support and help.
## How does it work?
This project has two components:
1. **server:** contains the implementation of [tachiyomi's extensions library](https://github.com/tachiyomiorg/extensions-lib) and uses an Android compatibility library to run apk extensions. All this concludes to serving a REST API to `webUI`.
2. **webUI:** A react SPA project that works with the server to do the presentation.
## Building from source ## Building from source
### Get Android stubs jar ### Get Android stubs jar
#### Manual download #### Manual download
Download [android.jar](https://raw.githubusercontent.com/AriaMoradi/Tachidesk/android-jar/android.jar) and put it under `AndroidCompat/lib`. Download [android.jar](https://raw.githubusercontent.com/AriaMoradi/Tachidesk/android-jar/android.jar) and put it under `AndroidCompat/lib`.
#### Automated download(needs `bash`, `curl`, `base64`, `zip` to work) #### Automated download(needs `bash`, `curl`, `base64`, `zip` to work)
Run `scripts/getAndroid.sh` from project's root directory to download and rebuild the jar file from Google's repository. Run `AndroidCompat/getAndroid.sh` from project's root directory to download and rebuild the jar file from Google's repository.
### building the jar ### building the jar
Run `./gradlew shadowJar`, the resulting built jar file will be `server/build/Tachidesk-vX.Y.Z-rxxx.jar`. Run `./gradlew shadowJar`, the resulting built jar file will be `server/build/Tachidesk-vX.Y.Z-rxxx.jar`.
### building the Windows package ### building the Windows package
@@ -41,23 +81,8 @@ How to do it is described in `webUI/react/README.md` but for short,
then open `http://127.0.0.1:3000` in a modern browser. This is a `create-react-app` project then open `http://127.0.0.1:3000` in a modern browser. This is a `create-react-app` project
and supports HMR and all the other goodies you'll need. and supports HMR and all the other goodies you'll need.
## Is this application usable? Should I test it?
If you'd ask me, I'd tell you If you want to read your manga **online** from tachiyomi or in one place and bypass all the ads, you can use Tachidesk.
There are almost no quality of life features, including no library, no downloading for offline enjoyment and sadly no MangaDex search.
Anyways, for more info checkout [finished milestone #1](https://github.com/AriaMoradi/Tachidesk/issues/2) and [milestone #2](https://github.com/AriaMoradi/Tachidesk/projects/1) to see what's implemented.
## How does it work?
This project has two components:
1. **server:** contains the implementation of [tachiyomi's extensions library](https://github.com/tachiyomiorg/extensions-lib) and uses an Android compatibility library to run apk extensions. All this concludes to serving a REST API to `webUI`.
2. **webUI:** A react SPA project that works with the server to do the presentation.
## Support
Join Tachidesk's [discord server](https://discord.gg/wgPyb7hE5d) to hang out with the community and receive support.
## Credit ## Credit
The `AndroidCompat` module and `scripts/getAndroid.sh` was originally developed by [@null-dev](https://github.com/null-dev) for [TachiWeb-Server](https://github.com/Tachiweb/TachiWeb-server) and is licensed under `Apache License Version 2.0`. The `AndroidCompat` module was originally developed by [@null-dev](https://github.com/null-dev) for [TachiWeb-Server](https://github.com/Tachiweb/TachiWeb-server) and is licensed under `Apache License Version 2.0`.
Parts of [tachiyomi](https://github.com/tachiyomiorg/tachiyomi) is adopted into this codebase, also licensed under `Apache License Version 2.0`. Parts of [tachiyomi](https://github.com/tachiyomiorg/tachiyomi) is adopted into this codebase, also licensed under `Apache License Version 2.0`.
+3
View File
@@ -76,5 +76,8 @@ configure(listOf(
// dependency of :AndroidCompat:Config // dependency of :AndroidCompat:Config
implementation("com.typesafe:config:1.4.0") implementation("com.typesafe:config:1.4.0")
// to get application content root
implementation("net.harawata:appdirs:1.2.0")
} }
} }
+5 -8
View File
@@ -9,7 +9,7 @@ plugins {
id("edu.sc.seis.launch4j") version "2.4.9" id("edu.sc.seis.launch4j") version "2.4.9"
} }
val TachideskVersion = "v0.2.2" val TachideskVersion = "v0.2.6"
repositories { repositories {
@@ -63,7 +63,7 @@ dependencies {
val coroutinesVersion = "1.3.9" val coroutinesVersion = "1.3.9"
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
// dex2jar // dex2jar: https://github.com/DexPatcher/dex2jar/releases/tag/v2.1-20190905-lanchon
implementation(fileTree("lib/dex2jar/")) implementation(fileTree("lib/dex2jar/"))
// api // api
@@ -72,9 +72,6 @@ dependencies {
implementation("org.slf4j:slf4j-api:1.8.0-beta4") implementation("org.slf4j:slf4j-api:1.8.0-beta4")
implementation("com.fasterxml.jackson.core:jackson-databind:2.10.3") implementation("com.fasterxml.jackson.core:jackson-databind:2.10.3")
// to get application content root
implementation("net.harawata:appdirs:1.2.0")
// Exposed ORM // Exposed ORM
val exposed_version = "0.28.1" val exposed_version = "0.28.1"
implementation("org.jetbrains.exposed:exposed-core:$exposed_version") implementation("org.jetbrains.exposed:exposed-core:$exposed_version")
@@ -91,8 +88,8 @@ dependencies {
implementation(project(":AndroidCompat:Config")) implementation(project(":AndroidCompat:Config"))
testImplementation("org.jetbrains.kotlin:kotlin-test") // testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit") // testImplementation("org.jetbrains.kotlin:kotlin-test-junit")
} }
val name = "ir.armor.tachidesk.Main" val name = "ir.armor.tachidesk.Main"
@@ -149,7 +146,7 @@ launch4j { //used for windows
bundledJre64Bit = true bundledJre64Bit = true
jreMinVersion = "8" jreMinVersion = "8"
outputDir = "Tachidesk-$TachideskVersion-$TachideskRevision-win32" outputDir = "Tachidesk-$TachideskVersion-$TachideskRevision-win32"
icon = "${projectDir}/src/main/resources/icon/icon_round.ico" icon = "${projectDir}/src/main/resources/icon/faviconlogo.ico"
jar = "${projectDir}/build/Tachidesk-$TachideskVersion-$TachideskRevision.jar" jar = "${projectDir}/build/Tachidesk-$TachideskVersion-$TachideskRevision.jar"
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,67 @@
==== dx-*.jar
Apache 2.0 http://www.apache.org/licenses/LICENSE-2.0.html
==== antlr-*.jar
[The BSD License]
Copyright (c) 2003-2007, Terence Parr
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of the author nor the names of its contributors
may be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
==== asm-*.jar
ASM: a very small and fast Java bytecode manipulation framework
Copyright (c) 2000-2005 INRIA, France Telecom
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holders nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
THE POSSIBILITY OF SUCH DAMAGE.
@@ -1,14 +0,0 @@
package ir.armor.tachidesk
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import net.harawata.appdirs.AppDirsFactory
object Config {
val dataRoot = AppDirsFactory.getInstance().getUserDataDir("Tachidesk", null, null)
val extensionsRoot = "$dataRoot/extensions"
val thumbnailsRoot = "$dataRoot/thumbnails"
val mangaRoot = "$dataRoot/manga"
}
@@ -4,11 +4,9 @@ package ir.armor.tachidesk
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.App
import io.javalin.Javalin import io.javalin.Javalin
import ir.armor.tachidesk.util.addMangaToCategory import ir.armor.tachidesk.util.addMangaToCategory
import ir.armor.tachidesk.util.addMangaToLibrary import ir.armor.tachidesk.util.addMangaToLibrary
import ir.armor.tachidesk.util.applicationSetup
import ir.armor.tachidesk.util.createCategory import ir.armor.tachidesk.util.createCategory
import ir.armor.tachidesk.util.getCategoryList import ir.armor.tachidesk.util.getCategoryList
import ir.armor.tachidesk.util.getCategoryMangaList import ir.armor.tachidesk.util.getCategoryMangaList
@@ -34,44 +32,13 @@ import ir.armor.tachidesk.util.reorderCategory
import ir.armor.tachidesk.util.sourceFilters import ir.armor.tachidesk.util.sourceFilters
import ir.armor.tachidesk.util.sourceGlobalSearch import ir.armor.tachidesk.util.sourceGlobalSearch
import ir.armor.tachidesk.util.sourceSearch import ir.armor.tachidesk.util.sourceSearch
import ir.armor.tachidesk.util.systemTray
import ir.armor.tachidesk.util.updateCategory import ir.armor.tachidesk.util.updateCategory
import org.kodein.di.DI
import org.kodein.di.conf.global
import xyz.nulldev.androidcompat.AndroidCompat
import xyz.nulldev.androidcompat.AndroidCompatInitializer
import xyz.nulldev.ts.config.ConfigKodeinModule
import xyz.nulldev.ts.config.GlobalConfigManager
class Main { class Main {
companion object { companion object {
val androidCompat by lazy { AndroidCompat() }
fun registerConfigModules() {
GlobalConfigManager.registerModules(
// ServerConfig.register(GlobalConfigManager.config),
// SyncConfigModule.register(GlobalConfigManager.config)
)
}
@JvmStatic @JvmStatic
fun main(args: Array<String>) { fun main(args: Array<String>) {
// System.getProperties()["proxySet"] = "true" serverSetup()
// System.getProperties()["socksProxyHost"] = "127.0.0.1"
// System.getProperties()["socksProxyPort"] = "2020"
// make sure everything we need exists
applicationSetup()
val tray = systemTray() // assign it to a variable so it's kept in the memory and not garbage collected
registerConfigModules()
// Load config API
DI.global.addImport(ConfigKodeinModule().create())
// Load Android compatibility dependencies
AndroidCompatInitializer().init()
// start app
androidCompat.startApp(App())
var hasWebUiBundled: Boolean = false var hasWebUiBundled: Boolean = false
@@ -86,15 +53,14 @@ class Main {
hasWebUiBundled = false hasWebUiBundled = false
} }
config.enableCorsForAllOrigins() config.enableCorsForAllOrigins()
}.start(4567) }.start(serverConfig.ip, serverConfig.port)
if (hasWebUiBundled) { if (hasWebUiBundled) {
openInBrowser() openInBrowser()
} }
// app.before() { ctx -> app.exception(NullPointerException::class.java) { _, ctx ->
// // allow the client which is running on another port ctx.status(404)
// ctx.header("Access-Control-Allow-Origin", "*") }
// }
app.get("/api/v1/extension/list") { ctx -> app.get("/api/v1/extension/list") { ctx ->
ctx.json(getExtensionList()) ctx.json(getExtensionList())
@@ -116,6 +82,7 @@ class Main {
ctx.status(200) ctx.status(200)
} }
// icon for extension named `apkName`
app.get("/api/v1/extension/icon/:apkName") { ctx -> app.get("/api/v1/extension/icon/:apkName") { ctx ->
val apkName = ctx.pathParam("apkName") val apkName = ctx.pathParam("apkName")
val result = getExtensionIcon(apkName) val result = getExtensionIcon(apkName)
@@ -124,31 +91,38 @@ class Main {
ctx.header("content-type", result.second) ctx.header("content-type", result.second)
} }
// list of sources
app.get("/api/v1/source/list") { ctx -> app.get("/api/v1/source/list") { ctx ->
ctx.json(getSourceList()) ctx.json(getSourceList())
} }
// fetch source with id `sourceId`
app.get("/api/v1/source/:sourceId") { ctx -> app.get("/api/v1/source/:sourceId") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong() val sourceId = ctx.pathParam("sourceId").toLong()
ctx.json(getSource(sourceId)) ctx.json(getSource(sourceId))
} }
// popular mangas from source with id `sourceId`
app.get("/api/v1/source/:sourceId/popular/:pageNum") { ctx -> app.get("/api/v1/source/:sourceId/popular/:pageNum") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong() val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt() val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(getMangaList(sourceId, pageNum, popular = true)) ctx.json(getMangaList(sourceId, pageNum, popular = true))
} }
// latest mangas from source with id `sourceId`
app.get("/api/v1/source/:sourceId/latest/:pageNum") { ctx -> app.get("/api/v1/source/:sourceId/latest/:pageNum") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong() val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt() val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(getMangaList(sourceId, pageNum, popular = false)) ctx.json(getMangaList(sourceId, pageNum, popular = false))
} }
// get manga info
app.get("/api/v1/manga/:mangaId/") { ctx -> app.get("/api/v1/manga/:mangaId/") { ctx ->
val mangaId = ctx.pathParam("mangaId").toInt() val mangaId = ctx.pathParam("mangaId").toInt()
ctx.json(getManga(mangaId)) ctx.json(getManga(mangaId))
} }
// manga thumbnail
app.get("api/v1/manga/:mangaId/thumbnail") { ctx -> app.get("api/v1/manga/:mangaId/thumbnail") { ctx ->
val mangaId = ctx.pathParam("mangaId").toInt() val mangaId = ctx.pathParam("mangaId").toInt()
val result = getThumbnail(mangaId) val result = getThumbnail(mangaId)
@@ -171,7 +145,7 @@ class Main {
ctx.status(200) ctx.status(200)
} }
// adds the manga to category // list manga's categories
app.get("api/v1/manga/:mangaId/category/") { ctx -> app.get("api/v1/manga/:mangaId/category/") { ctx ->
val mangaId = ctx.pathParam("mangaId").toInt() val mangaId = ctx.pathParam("mangaId").toInt()
ctx.json(getMangaCategories(mangaId)) ctx.json(getMangaCategories(mangaId))
@@ -198,10 +172,10 @@ class Main {
ctx.json(getChapterList(mangaId)) ctx.json(getChapterList(mangaId))
} }
app.get("/api/v1/manga/:mangaId/chapter/:chapterId") { ctx -> app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex") { ctx ->
val chapterId = ctx.pathParam("chapterId").toInt() val chapterIndex = ctx.pathParam("chapterIndex").toInt()
val mangaId = ctx.pathParam("mangaId").toInt() val mangaId = ctx.pathParam("mangaId").toInt()
ctx.json(getChapter(chapterId, mangaId)) ctx.json(getChapter(chapterIndex, mangaId))
} }
app.get("/api/v1/manga/:mangaId/chapter/:chapterId/page/:index") { ctx -> app.get("/api/v1/manga/:mangaId/chapter/:chapterId/page/:index") { ctx ->
@@ -253,7 +227,7 @@ class Main {
// category modification // category modification
app.patch("/api/v1/category/:categoryId") { ctx -> app.patch("/api/v1/category/:categoryId") { ctx ->
val categoryId = ctx.pathParam("categoryId")!!.toInt() val categoryId = ctx.pathParam("categoryId").toInt()
val name = ctx.formParam("name") val name = ctx.formParam("name")
val isLanding = if (ctx.formParam("isLanding") != null) ctx.formParam("isLanding")?.toBoolean() else null val isLanding = if (ctx.formParam("isLanding") != null) ctx.formParam("isLanding")?.toBoolean() else null
updateCategory(categoryId, name, isLanding) updateCategory(categoryId, name, isLanding)
@@ -0,0 +1,25 @@
package ir.armor.tachidesk
import com.typesafe.config.Config
import xyz.nulldev.ts.config.ConfigModule
import java.io.File
class ServerConfig(config: Config) : ConfigModule(config) {
val ip = config.getString("ip")
val port = config.getInt("port")
// proxy
val socksProxy = config.getBoolean("socksProxy")
val socksProxyHost = config.getString("socksProxyHost")
val socksProxyPort = config.getString("socksProxyPort")
fun registerFile(file: String): File {
return File(file).apply {
mkdirs()
}
}
companion object {
fun register(config: Config) = ServerConfig(config.getConfig("server"))
}
}
@@ -0,0 +1,60 @@
package ir.armor.tachidesk
import eu.kanade.tachiyomi.App
import ir.armor.tachidesk.database.makeDataBaseTables
import ir.armor.tachidesk.util.systemTray
import net.harawata.appdirs.AppDirsFactory
import org.kodein.di.DI
import org.kodein.di.conf.global
import xyz.nulldev.androidcompat.AndroidCompat
import xyz.nulldev.androidcompat.AndroidCompatInitializer
import xyz.nulldev.ts.config.ConfigKodeinModule
import xyz.nulldev.ts.config.GlobalConfigManager
import java.io.File
object applicationDirs {
val dataRoot = AppDirsFactory.getInstance().getUserDataDir("Tachidesk", null, null)!!
val extensionsRoot = "$dataRoot/extensions"
val thumbnailsRoot = "$dataRoot/thumbnails"
val mangaRoot = "$dataRoot/manga"
}
val serverConfig: ServerConfig by lazy { GlobalConfigManager.module() }
val systemTray by lazy { systemTray() }
val androidCompat by lazy { AndroidCompat() }
fun serverSetup() {
// register server config
GlobalConfigManager.registerModule(
ServerConfig.register(GlobalConfigManager.config)
)
// make dirs we need
listOf(
applicationDirs.dataRoot,
applicationDirs.extensionsRoot,
"${applicationDirs.extensionsRoot}/icon",
applicationDirs.thumbnailsRoot
).forEach {
File(it).mkdirs()
}
makeDataBaseTables()
// create system tray
systemTray
// Load config API
DI.global.addImport(ConfigKodeinModule().create())
// Load Android compatibility dependencies
AndroidCompatInitializer().init()
// start app
androidCompat.startApp(App())
// socks proxy settings
System.getProperties()["proxySet"] = serverConfig.socksProxy.toString()
System.getProperties()["socksProxyHost"] = serverConfig.socksProxyHost
System.getProperties()["socksProxyPort"] = serverConfig.socksProxyPort
}
@@ -4,7 +4,7 @@ package ir.armor.tachidesk.database
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import ir.armor.tachidesk.Config import ir.armor.tachidesk.applicationDirs
import ir.armor.tachidesk.database.table.CategoryMangaTable import ir.armor.tachidesk.database.table.CategoryMangaTable
import ir.armor.tachidesk.database.table.CategoryTable import ir.armor.tachidesk.database.table.CategoryTable
import ir.armor.tachidesk.database.table.ChapterTable import ir.armor.tachidesk.database.table.ChapterTable
@@ -18,15 +18,14 @@ import org.jetbrains.exposed.sql.transactions.transaction
object DBMangaer { object DBMangaer {
val db by lazy { val db by lazy {
Database.connect("jdbc:h2:${Config.dataRoot}/database", "org.h2.Driver") Database.connect("jdbc:h2:${applicationDirs.dataRoot}/database", "org.h2.Driver")
} }
} }
fun makeDataBaseTables() { fun makeDataBaseTables() {
// mention db object to connect // must mention db object so the lazy block executes
DBMangaer.db val db = DBMangaer.db
// val db = DBMangaer.db db.useNestedTransactions = true
// db.useNestedTransactions = true
transaction { transaction {
SchemaUtils.createMissingTablesAndColumns( SchemaUtils.createMissingTablesAndColumns(
@@ -12,5 +12,7 @@ data class ChapterDataClass(
val chapter_number: Float, val chapter_number: Float,
val scanlator: String?, val scanlator: String?,
val mangaId: Int, val mangaId: Int,
val chapterIndex: Int,
val chapterCount: Int,
val pageCount: Int? = null, val pageCount: Int? = null,
) )
@@ -8,7 +8,7 @@ import ir.armor.tachidesk.database.table.MangaStatus
data class MangaDataClass( data class MangaDataClass(
val id: Int, val id: Int,
val sourceId: Long, val sourceId: String,
val url: String, val url: String,
val title: String, val title: String,
@@ -21,7 +21,8 @@ data class MangaDataClass(
val description: String? = null, val description: String? = null,
val genre: String? = null, val genre: String? = null,
val status: String = MangaStatus.UNKNOWN.name, val status: String = MangaStatus.UNKNOWN.name,
val inLibrary: Boolean = false val inLibrary: Boolean = false,
val source: SourceDataClass? = null
) )
data class PagedMangaListDataClass( data class PagedMangaListDataClass(
@@ -6,8 +6,8 @@ package ir.armor.tachidesk.database.dataclass
data class SourceDataClass( data class SourceDataClass(
val id: String, val id: String,
val name: String, val name: String?,
val lang: String, val lang: String?,
val iconUrl: String, val iconUrl: String?,
val supportsLatest: Boolean val supportsLatest: Boolean?
) )
@@ -13,5 +13,7 @@ object ChapterTable : IntIdTable() {
val chapter_number = float("chapter_number").default(-1f) val chapter_number = float("chapter_number").default(-1f)
val scanlator = varchar("scanlator", 128).nullable() val scanlator = varchar("scanlator", 128).nullable()
val chapterIndex = integer("number_in_list")
val manga = reference("manga", MangaTable) val manga = reference("manga", MangaTable)
} }
@@ -28,13 +28,13 @@ object MangaTable : IntIdTable() {
val defaultCategory = bool("default_category").default(true) val defaultCategory = bool("default_category").default(true)
// source is used by some ancestor of IntIdTable // source is used by some ancestor of IntIdTable
val sourceReference = reference("source", SourceTable) val sourceReference = long("source")
} }
fun MangaTable.toDataClass(mangaEntry: ResultRow) = fun MangaTable.toDataClass(mangaEntry: ResultRow) =
MangaDataClass( MangaDataClass(
mangaEntry[MangaTable.id].value, mangaEntry[MangaTable.id].value,
mangaEntry[sourceReference].value, mangaEntry[sourceReference].toString(),
mangaEntry[MangaTable.url], mangaEntry[MangaTable.url],
mangaEntry[MangaTable.title], mangaEntry[MangaTable.title],
@@ -1,6 +1,7 @@
package ir.armor.tachidesk.util package ir.armor.tachidesk.util
import ir.armor.tachidesk.database.dataclass.CategoryDataClass import ir.armor.tachidesk.database.dataclass.CategoryDataClass
import ir.armor.tachidesk.database.table.CategoryMangaTable
import ir.armor.tachidesk.database.table.CategoryTable import ir.armor.tachidesk.database.table.CategoryTable
import ir.armor.tachidesk.database.table.toDataClass import ir.armor.tachidesk.database.table.toDataClass
import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SortOrder
@@ -49,6 +50,9 @@ fun reorderCategory(categoryId: Int, from: Int, to: Int) {
fun removeCategory(categoryId: Int) { fun removeCategory(categoryId: Int) {
transaction { transaction {
CategoryMangaTable.select { CategoryMangaTable.category eq categoryId }.forEach {
removeMangaFromCategory(it[CategoryMangaTable.manga].value, categoryId)
}
CategoryTable.deleteWhere { CategoryTable.id eq categoryId } CategoryTable.deleteWhere { CategoryTable.id eq categoryId }
} }
} }
@@ -14,11 +14,13 @@ import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.insertAndGetId import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
fun getChapterList(mangaId: Int): List<ChapterDataClass> { fun getChapterList(mangaId: Int): List<ChapterDataClass> {
val mangaDetails = getManga(mangaId) val mangaDetails = getManga(mangaId)
val source = getHttpSource(mangaDetails.sourceId) val source = getHttpSource(mangaDetails.sourceId.toLong())
val chapterList = source.fetchChapterList( val chapterList = source.fetchChapterList(
SManga.create().apply { SManga.create().apply {
@@ -27,8 +29,10 @@ fun getChapterList(mangaId: Int): List<ChapterDataClass> {
} }
).toBlocking().first() ).toBlocking().first()
val chapterCount = chapterList.count()
return transaction { return transaction {
chapterList.forEach { fetchedChapter -> chapterList.reversed().forEachIndexed { index, fetchedChapter ->
val chapterEntry = ChapterTable.select { ChapterTable.url eq fetchedChapter.url }.firstOrNull() val chapterEntry = ChapterTable.select { ChapterTable.url eq fetchedChapter.url }.firstOrNull()
if (chapterEntry == null) { if (chapterEntry == null) {
ChapterTable.insertAndGetId { ChapterTable.insertAndGetId {
@@ -38,12 +42,29 @@ fun getChapterList(mangaId: Int): List<ChapterDataClass> {
it[chapter_number] = fetchedChapter.chapter_number it[chapter_number] = fetchedChapter.chapter_number
it[scanlator] = fetchedChapter.scanlator it[scanlator] = fetchedChapter.scanlator
it[chapterIndex] = index + 1
it[manga] = mangaId
}
} else {
ChapterTable.update({ ChapterTable.url eq fetchedChapter.url }) {
it[name] = fetchedChapter.name
it[date_upload] = fetchedChapter.date_upload
it[chapter_number] = fetchedChapter.chapter_number
it[scanlator] = fetchedChapter.scanlator
it[chapterIndex] = index + 1
it[manga] = mangaId it[manga] = mangaId
} }
} }
} }
return@transaction chapterList.map { // clear any orphaned chapters
val dbChapterCount = transaction { ChapterTable.selectAll().count() }
if (dbChapterCount > chapterCount) { // we got some clean up due
// TODO
}
return@transaction chapterList.mapIndexed { index, it ->
ChapterDataClass( ChapterDataClass(
ChapterTable.select { ChapterTable.url eq it.url }.firstOrNull()!![ChapterTable.id].value, ChapterTable.select { ChapterTable.url eq it.url }.firstOrNull()!![ChapterTable.id].value,
it.url, it.url,
@@ -51,18 +72,21 @@ fun getChapterList(mangaId: Int): List<ChapterDataClass> {
it.date_upload, it.date_upload,
it.chapter_number, it.chapter_number,
it.scanlator, it.scanlator,
mangaId mangaId,
chapterCount - index,
chapterCount
) )
} }
} }
} }
fun getChapter(chapterId: Int, mangaId: Int): ChapterDataClass { fun getChapter(chapterIndex: Int, mangaId: Int): ChapterDataClass {
return transaction { return transaction {
val chapterEntry = ChapterTable.select { ChapterTable.id eq chapterId }.firstOrNull()!! val chapterEntry = ChapterTable.select {
assert(mangaId == chapterEntry[ChapterTable.manga].value) // sanity check ChapterTable.chapterIndex eq chapterIndex and (ChapterTable.manga eq mangaId)
}.firstOrNull()!!
val mangaEntry = MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! val mangaEntry = MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!!
val source = getHttpSource(mangaEntry[MangaTable.sourceReference].value) val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
val pageList = source.fetchPageList( val pageList = source.fetchPageList(
SChapter.create().apply { SChapter.create().apply {
@@ -71,14 +95,20 @@ fun getChapter(chapterId: Int, mangaId: Int): ChapterDataClass {
} }
).toBlocking().first() ).toBlocking().first()
val chapterId = chapterEntry[ChapterTable.id].value
val chapterCount = transaction { ChapterTable.selectAll().count() }
val chapter = ChapterDataClass( val chapter = ChapterDataClass(
chapterEntry[ChapterTable.id].value, chapterId,
chapterEntry[ChapterTable.url], chapterEntry[ChapterTable.url],
chapterEntry[ChapterTable.name], chapterEntry[ChapterTable.name],
chapterEntry[ChapterTable.date_upload], chapterEntry[ChapterTable.date_upload],
chapterEntry[ChapterTable.chapter_number], chapterEntry[ChapterTable.chapter_number],
chapterEntry[ChapterTable.scanlator], chapterEntry[ChapterTable.scanlator],
mangaId, mangaId,
chapterEntry[ChapterTable.chapterIndex],
chapterCount.toInt(),
pageList.count() pageList.count()
) )
@@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import ir.armor.tachidesk.APKExtractor import ir.armor.tachidesk.APKExtractor
import ir.armor.tachidesk.Config import ir.armor.tachidesk.applicationDirs
import ir.armor.tachidesk.database.table.ExtensionTable import ir.armor.tachidesk.database.table.ExtensionTable
import ir.armor.tachidesk.database.table.SourceTable import ir.armor.tachidesk.database.table.SourceTable
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -32,10 +32,10 @@ import java.net.URLClassLoader
fun installAPK(apkName: String): Int { fun installAPK(apkName: String): Int {
val extensionRecord = getExtensionList(true).first { it.apkName == apkName } val extensionRecord = getExtensionList(true).first { it.apkName == apkName }
val fileNameWithoutType = apkName.substringBefore(".apk") val fileNameWithoutType = apkName.substringBefore(".apk")
val dirPathWithoutType = "${Config.extensionsRoot}/$fileNameWithoutType" val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
// check if we don't have the dex file already downloaded // check if we don't have the dex file already downloaded
val jarPath = "${Config.extensionsRoot}/$fileNameWithoutType.jar" val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
if (!File(jarPath).exists()) { if (!File(jarPath).exists()) {
runBlocking { runBlocking {
val api = ExtensionGithubApi() val api = ExtensionGithubApi()
@@ -137,7 +137,7 @@ private fun downloadAPKFile(url: String, apkPath: String) {
fun removeExtension(pkgName: String) { fun removeExtension(pkgName: String) {
val extensionRecord = getExtensionList(true).first { it.apkName == pkgName } val extensionRecord = getExtensionList(true).first { it.apkName == pkgName }
val fileNameWithoutType = pkgName.substringBefore(".apk") val fileNameWithoutType = pkgName.substringBefore(".apk")
val jarPath = "${Config.extensionsRoot}/$fileNameWithoutType.jar" val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
transaction { transaction {
val extensionId = ExtensionTable.select { ExtensionTable.name eq extensionRecord.name }.first()[ExtensionTable.id] val extensionId = ExtensionTable.select { ExtensionTable.name eq extensionRecord.name }.first()[ExtensionTable.id]
@@ -157,7 +157,7 @@ val network: NetworkHelper by injectLazy()
fun getExtensionIcon(apkName: String): Pair<InputStream, String> { fun getExtensionIcon(apkName: String): Pair<InputStream, String> {
val iconUrl = transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.firstOrNull()!! }[ExtensionTable.iconUrl] val iconUrl = transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.firstOrNull()!! }[ExtensionTable.iconUrl]
val saveDir = "${Config.extensionsRoot}/icon" val saveDir = "${applicationDirs.extensionsRoot}/icon"
val fileName = apkName val fileName = apkName
return getCachedResponse(saveDir, fileName) { return getCachedResponse(saveDir, fileName) {
@@ -168,5 +168,5 @@ fun getExtensionIcon(apkName: String): Pair<InputStream, String> {
} }
fun getExtensionIconUrl(apkName: String): String { fun getExtensionIconUrl(apkName: String): String {
return "http://127.0.0.1:4567/api/v1/extension/icon/$apkName" return "/api/v1/extension/icon/$apkName"
} }
@@ -6,7 +6,7 @@ package ir.armor.tachidesk.util
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import ir.armor.tachidesk.Config import ir.armor.tachidesk.applicationDirs
import ir.armor.tachidesk.database.dataclass.MangaDataClass import ir.armor.tachidesk.database.dataclass.MangaDataClass
import ir.armor.tachidesk.database.table.MangaStatus import ir.armor.tachidesk.database.table.MangaStatus
import ir.armor.tachidesk.database.table.MangaTable import ir.armor.tachidesk.database.table.MangaTable
@@ -21,7 +21,7 @@ fun getManga(mangaId: Int, proxyThumbnail: Boolean = true): MangaDataClass {
return if (mangaEntry[MangaTable.initialized]) { return if (mangaEntry[MangaTable.initialized]) {
MangaDataClass( MangaDataClass(
mangaId, mangaId,
mangaEntry[MangaTable.sourceReference].value, mangaEntry[MangaTable.sourceReference].toString(),
mangaEntry[MangaTable.url], mangaEntry[MangaTable.url],
mangaEntry[MangaTable.title], mangaEntry[MangaTable.title],
@@ -34,10 +34,11 @@ fun getManga(mangaId: Int, proxyThumbnail: Boolean = true): MangaDataClass {
mangaEntry[MangaTable.description], mangaEntry[MangaTable.description],
mangaEntry[MangaTable.genre], mangaEntry[MangaTable.genre],
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name, MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
mangaEntry[MangaTable.inLibrary] mangaEntry[MangaTable.inLibrary],
getSource(mangaEntry[MangaTable.sourceReference])
) )
} else { // initialize manga } else { // initialize manga
val source = getHttpSource(mangaEntry[MangaTable.sourceReference].value) val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
val fetchedManga = source.fetchMangaDetails( val fetchedManga = source.fetchMangaDetails(
SManga.create().apply { SManga.create().apply {
url = mangaEntry[MangaTable.url] url = mangaEntry[MangaTable.url]
@@ -65,7 +66,7 @@ fun getManga(mangaId: Int, proxyThumbnail: Boolean = true): MangaDataClass {
MangaDataClass( MangaDataClass(
mangaId, mangaId,
mangaEntry[MangaTable.sourceReference].value, mangaEntry[MangaTable.sourceReference].toString(),
mangaEntry[MangaTable.url], mangaEntry[MangaTable.url],
mangaEntry[MangaTable.title], mangaEntry[MangaTable.title],
@@ -78,18 +79,19 @@ fun getManga(mangaId: Int, proxyThumbnail: Boolean = true): MangaDataClass {
fetchedManga.description, fetchedManga.description,
fetchedManga.genre, fetchedManga.genre,
MangaStatus.valueOf(fetchedManga.status).name, MangaStatus.valueOf(fetchedManga.status).name,
false false,
getSource(mangaEntry[MangaTable.sourceReference])
) )
} }
} }
fun getThumbnail(mangaId: Int): Pair<InputStream, String> { fun getThumbnail(mangaId: Int): Pair<InputStream, String> {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! } val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }
val saveDir = Config.thumbnailsRoot val saveDir = applicationDirs.thumbnailsRoot
val fileName = mangaId.toString() val fileName = mangaId.toString()
return getCachedResponse(saveDir, fileName) { return getCachedResponse(saveDir, fileName) {
val sourceId = mangaEntry[MangaTable.sourceReference].value val sourceId = mangaEntry[MangaTable.sourceReference]
val source = getHttpSource(sourceId) val source = getHttpSource(sourceId)
var thumbnailUrl = mangaEntry[MangaTable.thumbnail_url] var thumbnailUrl = mangaEntry[MangaTable.thumbnail_url]
if (thumbnailUrl == null || thumbnailUrl.isEmpty()) { if (thumbnailUrl == null || thumbnailUrl.isEmpty()) {
@@ -14,7 +14,7 @@ import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
fun proxyThumbnailUrl(mangaId: Int): String { fun proxyThumbnailUrl(mangaId: Int): String {
return "http://127.0.0.1:4567/api/v1/manga/$mangaId/thumbnail" return "/api/v1/manga/$mangaId/thumbnail"
} }
fun getMangaList(sourceId: Long, pageNum: Int = 1, popular: Boolean): PagedMangaListDataClass { fun getMangaList(sourceId: Long, pageNum: Int = 1, popular: Boolean): PagedMangaListDataClass {
@@ -52,7 +52,7 @@ fun MangasPage.processEntries(sourceId: Long): PagedMangaListDataClass {
MangaDataClass( MangaDataClass(
mangaId, mangaId,
sourceId, sourceId.toString(),
manga.url, manga.url,
manga.title, manga.title,
@@ -70,7 +70,7 @@ fun MangasPage.processEntries(sourceId: Long): PagedMangaListDataClass {
val mangaId = mangaEntry[MangaTable.id].value val mangaId = mangaEntry[MangaTable.id].value
MangaDataClass( MangaDataClass(
mangaId, mangaId,
sourceId, sourceId.toString(),
manga.url, manga.url,
manga.title, manga.title,
@@ -6,12 +6,11 @@ package ir.armor.tachidesk.util
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import ir.armor.tachidesk.Config import ir.armor.tachidesk.applicationDirs
import ir.armor.tachidesk.database.table.ChapterTable import ir.armor.tachidesk.database.table.ChapterTable
import ir.armor.tachidesk.database.table.MangaTable import ir.armor.tachidesk.database.table.MangaTable
import ir.armor.tachidesk.database.table.PageTable import ir.armor.tachidesk.database.table.PageTable
import ir.armor.tachidesk.database.table.SourceTable import ir.armor.tachidesk.database.table.SourceTable
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
@@ -28,7 +27,7 @@ fun getTrueImageUrl(page: Page, source: HttpSource): String {
fun getPageImage(mangaId: Int, chapterId: Int, index: Int): Pair<InputStream, String> { fun getPageImage(mangaId: Int, chapterId: Int, index: Int): Pair<InputStream, String> {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! } val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }
val source = getHttpSource(mangaEntry[MangaTable.sourceReference].value) val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
val chapterEntry = transaction { ChapterTable.select { ChapterTable.id eq chapterId }.firstOrNull()!! } val chapterEntry = transaction { ChapterTable.select { ChapterTable.id eq chapterId }.firstOrNull()!! }
val pageEntry = transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq index) }.firstOrNull()!! } val pageEntry = transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq index) }.firstOrNull()!! }
@@ -57,7 +56,7 @@ fun getPageImage(mangaId: Int, chapterId: Int, index: Int): Pair<InputStream, St
fun getChapterDir(mangaId: Int, chapterId: Int): String { fun getChapterDir(mangaId: Int, chapterId: Int): String {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! } val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }
val sourceId = mangaEntry[MangaTable.sourceReference].value val sourceId = mangaEntry[MangaTable.sourceReference]
val source = getHttpSource(sourceId) val source = getHttpSource(sourceId)
val sourceEntry = transaction { SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()!! } val sourceEntry = transaction { SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()!! }
val chapterEntry = transaction { ChapterTable.select { ChapterTable.id eq chapterId }.firstOrNull()!! } val chapterEntry = transaction { ChapterTable.select { ChapterTable.id eq chapterId }.firstOrNull()!! }
@@ -70,7 +69,7 @@ fun getChapterDir(mangaId: Int, chapterId: Int): String {
val mangaTitle = mangaEntry[MangaTable.title] val mangaTitle = mangaEntry[MangaTable.title]
val sourceName = source.toString() val sourceName = source.toString()
val mangaDir = "${Config.mangaRoot}/$sourceName/$mangaTitle/$chapterDir" val mangaDir = "${applicationDirs.mangaRoot}/$sourceName/$mangaTitle/$chapterDir"
// make sure dirs exist // make sure dirs exist
File(mangaDir).mkdirs() File(mangaDir).mkdirs()
return mangaDir return mangaDir
@@ -18,6 +18,7 @@ fun sourceSearch(sourceId: Long, searchTerm: String, pageNum: Int): PagedMangaLi
} }
fun sourceGlobalSearch(searchTerm: String) { fun sourceGlobalSearch(searchTerm: String) {
// TODO
} }
data class FilterWrapper( data class FilterWrapper(
@@ -6,7 +6,7 @@ package ir.armor.tachidesk.util
import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import ir.armor.tachidesk.Config import ir.armor.tachidesk.applicationDirs
import ir.armor.tachidesk.database.dataclass.SourceDataClass import ir.armor.tachidesk.database.dataclass.SourceDataClass
import ir.armor.tachidesk.database.entity.ExtensionEntity import ir.armor.tachidesk.database.entity.ExtensionEntity
import ir.armor.tachidesk.database.entity.SourceEntity import ir.armor.tachidesk.database.entity.SourceEntity
@@ -15,14 +15,18 @@ import ir.armor.tachidesk.database.table.SourceTable
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import java.lang.NullPointerException
import java.net.URL import java.net.URL
import java.net.URLClassLoader import java.net.URLClassLoader
import java.util.Locale
private val sourceCache = mutableListOf<Pair<Long, HttpSource>>() private val sourceCache = mutableListOf<Pair<Long, HttpSource>>()
private val extensionCache = mutableListOf<Pair<String, Any>>() private val extensionCache = mutableListOf<Pair<String, Any>>()
fun getHttpSource(sourceId: Long): HttpSource { fun getHttpSource(sourceId: Long): HttpSource {
val sourceRecord = transaction {
SourceEntity.findById(sourceId)
} ?: throw NullPointerException("Source with id $sourceId is not installed")
val cachedResult: Pair<Long, HttpSource>? = sourceCache.firstOrNull { it.first == sourceId } val cachedResult: Pair<Long, HttpSource>? = sourceCache.firstOrNull { it.first == sourceId }
if (cachedResult != null) { if (cachedResult != null) {
println("used cached HttpSource: ${cachedResult.second.name}") println("used cached HttpSource: ${cachedResult.second.name}")
@@ -30,13 +34,12 @@ fun getHttpSource(sourceId: Long): HttpSource {
} }
val result: HttpSource = transaction { val result: HttpSource = transaction {
val sourceRecord = SourceEntity.findById(sourceId)!!
val extensionId = sourceRecord.extension.id.value val extensionId = sourceRecord.extension.id.value
val extensionRecord = ExtensionEntity.findById(extensionId)!! val extensionRecord = ExtensionEntity.findById(extensionId)!!
val apkName = extensionRecord.apkName val apkName = extensionRecord.apkName
val className = extensionRecord.classFQName val className = extensionRecord.classFQName
val jarName = apkName.substringBefore(".apk") + ".jar" val jarName = apkName.substringBefore(".apk") + ".jar"
val jarPath = "${Config.extensionsRoot}/$jarName" val jarPath = "${applicationDirs.extensionsRoot}/$jarName"
println(jarName) println(jarName)
@@ -77,7 +80,7 @@ fun getSourceList(): List<SourceDataClass> {
SourceDataClass( SourceDataClass(
it[SourceTable.id].value.toString(), it[SourceTable.id].value.toString(),
it[SourceTable.name], it[SourceTable.name],
Locale(it[SourceTable.lang]).getDisplayLanguage(Locale(it[SourceTable.lang])), it[SourceTable.lang],
getExtensionIconUrl(ExtensionTable.select { ExtensionTable.id eq it[SourceTable.extension] }.first()[ExtensionTable.apkName]), getExtensionIconUrl(ExtensionTable.select { ExtensionTable.id eq it[SourceTable.extension] }.first()[ExtensionTable.apkName]),
getHttpSource(it[SourceTable.id].value).supportsLatest getHttpSource(it[SourceTable.id].value).supportsLatest
) )
@@ -87,14 +90,14 @@ fun getSourceList(): List<SourceDataClass> {
fun getSource(sourceId: Long): SourceDataClass { fun getSource(sourceId: Long): SourceDataClass {
return transaction { return transaction {
val source = SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()!! val source = SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()
return@transaction SourceDataClass( return@transaction SourceDataClass(
source[SourceTable.id].value.toString(), sourceId.toString(),
source[SourceTable.name], source?.get(SourceTable.name),
Locale(source[SourceTable.lang]).getDisplayLanguage(Locale(source[SourceTable.lang])), source?.get(SourceTable.lang),
ExtensionTable.select { ExtensionTable.id eq source[SourceTable.extension] }.first()[ExtensionTable.iconUrl], source?.let { ExtensionTable.select { ExtensionTable.id eq source[SourceTable.extension] }.first()[ExtensionTable.iconUrl] },
getHttpSource(source[SourceTable.id].value).supportsLatest source?.let { getHttpSource(sourceId).supportsLatest }
) )
} }
} }
@@ -9,63 +9,59 @@ import dorkbox.systemTray.SystemTray
import dorkbox.systemTray.SystemTray.TrayType import dorkbox.systemTray.SystemTray.TrayType
import dorkbox.util.CacheUtil import dorkbox.util.CacheUtil
import dorkbox.util.Desktop import dorkbox.util.Desktop
import ir.armor.tachidesk.Config
import ir.armor.tachidesk.Main import ir.armor.tachidesk.Main
import ir.armor.tachidesk.database.makeDataBaseTables
import java.awt.event.ActionListener import java.awt.event.ActionListener
import java.io.File
import java.io.IOException import java.io.IOException
fun applicationSetup() {
// make dirs we need
File(Config.dataRoot).mkdirs()
File(Config.extensionsRoot).mkdirs()
File("${Config.extensionsRoot}/icon").mkdirs()
File(Config.thumbnailsRoot).mkdirs()
makeDataBaseTables()
}
fun openInBrowser() { fun openInBrowser() {
Desktop.browseURL("http://127.0.0.1:4567") try {
Desktop.browseURL("http://127.0.0.1:4567")
} catch (e1: IOException) {
e1.printStackTrace()
}
} }
val icon = Main::class.java.getResource("/icon/icon_round.png")
fun systemTray(): SystemTray? { fun systemTray(): SystemTray? {
// ref: https://github.com/dorkbox/SystemTray/blob/master/test/dorkbox/TestTray.java try {
SystemTray.DEBUG = true; // for test apps, we always want to run in debug mode // ref: https://github.com/dorkbox/SystemTray/blob/master/test/dorkbox/TestTray.java
if (System.getProperty("os.name").startsWith("Windows")) SystemTray.DEBUG = true; // for test apps, we always want to run in debug mode
SystemTray.FORCE_TRAY_TYPE = TrayType.Swing if (System.getProperty("os.name").startsWith("Windows"))
SystemTray.FORCE_TRAY_TYPE = TrayType.Swing
CacheUtil.clear() CacheUtil.clear()
val systemTray = SystemTray.get() ?: return null val systemTray = SystemTray.get() ?: return null
val mainMenu = systemTray.menu val mainMenu = systemTray.menu
mainMenu.add( mainMenu.add(
MenuItem( MenuItem(
"Open Tachidesk", "Open Tachidesk",
ActionListener { ActionListener {
try { try {
Desktop.browseURL("http://127.0.0.1:4567") Desktop.browseURL("http://127.0.0.1:4567")
} catch (e1: IOException) { } catch (e: IOException) {
e1.printStackTrace() e.printStackTrace()
}
} }
} )
) )
)
val icon = Main::class.java.getResource("/icon/faviconlogo.png")
// systemTray.setTooltip("Tachidesk") // systemTray.setTooltip("Tachidesk")
systemTray.setImage(icon) systemTray.setImage(icon)
// systemTray.status = "No Mail" // systemTray.status = "No Mail"
systemTray.getMenu().add( systemTray.getMenu().add(
MenuItem("Quit") { MenuItem("Quit") {
systemTray.shutdown() systemTray.shutdown()
System.exit(0) System.exit(0)
} }
) )
return systemTray return systemTray
} catch (e: Exception) {
e.printStackTrace()
return null
}
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,8 @@
# Server ip and port bindings
server.ip = 0.0.0.0
server.port = 4567
# Socks5 proxy
server.socksProxy = false
server.socksProxyHost = ""
server.socksProxyPort = ""
@@ -1,13 +0,0 @@
/*
* This Kotlin source file was generated by the Gradle 'init' task.
*/
package ir.armor.tachidesk
import kotlin.test.Test
import kotlin.test.assertTrue
class AppTest {
@Test fun testAppHasAGreeting() {
assertTrue(true)
}
}
+2
View File
@@ -13,5 +13,7 @@ module.exports = {
// Indent props with 4 spaces // Indent props with 4 spaces
'react/jsx-indent-props': ['error', 4], 'react/jsx-indent-props': ['error', 4],
'no-plusplus': ['error', { 'allowForLoopAfterthoughts': true }]
}, },
}; };
+3
View File
@@ -8,10 +8,13 @@
"@testing-library/jest-dom": "^5.11.4", "@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0", "@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10", "@testing-library/user-event": "^12.1.10",
"@types/react-lazyload": "^3.1.0",
"axios": "^0.21.1",
"fontsource-roboto": "^4.0.0", "fontsource-roboto": "^4.0.0",
"react": "^17.0.1", "react": "^17.0.1",
"react-beautiful-dnd": "^13.0.0", "react-beautiful-dnd": "^13.0.0",
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"react-lazyload": "^3.2.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scripts": "4.0.1", "react-scripts": "4.0.1",
"web-vitals": "^0.2.4" "web-vitals": "^0.2.4"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

+3 -3
View File
@@ -7,9 +7,9 @@
<meta name="theme-color" content="#000000"/> <meta name="theme-color" content="#000000"/>
<meta <meta
name="description" name="description"
content="Web site created using create-react-app" content="A manga reader that runs tachiyomi's extensions"
/> />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png"/> <link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.png"/>
<!-- <!--
manifest.json provides metadata used when your web app is installed on a manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
@@ -24,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<title>React App</title> <title>Tachidesk</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

+12 -6
View File
@@ -1,6 +1,6 @@
{ {
"short_name": "React App", "short_name": "Tachidesk",
"name": "Create React App Sample", "name": "Tachidesk",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "favicon.ico",
@@ -8,18 +8,24 @@
"type": "image/x-icon" "type": "image/x-icon"
}, },
{ {
"src": "logo192.png", "src": "favicon.png",
"type": "image/png", "type": "image/png",
"sizes": "192x192" "sizes": "192x192"
}, },
{ {
"src": "logo512.png", "src": "favicon.png",
"type": "image/png", "type": "image/png",
"sizes": "512x512" "sizes": "512x512"
},
{
"src": "favicon.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
} }
], ],
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"theme_color": "#000000", "theme_color": "#ff2323",
"background_color": "#ffffff" "background_color": "#ff2323"
} }
+25 -8
View File
@@ -17,16 +17,23 @@ import SourceMangas from './screens/SourceMangas';
import Manga from './screens/Manga'; import Manga from './screens/Manga';
import Reader from './screens/Reader'; import Reader from './screens/Reader';
import Search from './screens/SearchSingle'; import Search from './screens/SearchSingle';
import NavBarTitle from './context/NavbarTitle'; import NavbarContext from './context/NavbarContext';
import DarkTheme from './context/DarkTheme'; import DarkTheme from './context/DarkTheme';
import Library from './screens/Library'; import Library from './screens/Library';
import Settings from './screens/Settings'; import Settings from './screens/Settings';
import Categories from './screens/settings/Categories'; import Categories from './screens/settings/Categories';
import useLocalStorage from './util/useLocalStorage';
export default function App() { export default function App() {
const [title, setTitle] = useState<string>('Tachidesk'); const [title, setTitle] = useState<string>('Tachidesk');
const [darkTheme, setDarkTheme] = useState<boolean>(true); const [action, setAction] = useState<any>(<div />);
const navTitleContext = { title, setTitle }; const [override, setOverride] = useState<INavbarOverride>({ status: false, value: <div /> });
const [darkTheme, setDarkTheme] = useLocalStorage<boolean>('darkTheme', true);
const navBarContext = {
title, setTitle, action, setAction, override, setOverride,
};
const darkThemeContext = { darkTheme, setDarkTheme }; const darkThemeContext = { darkTheme, setDarkTheme };
const theme = React.useMemo( const theme = React.useMemo(
@@ -56,10 +63,15 @@ export default function App() {
return ( return (
<Router> <Router>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<NavBarTitle.Provider value={navTitleContext}> <NavbarContext.Provider value={navBarContext}>
<CssBaseline /> <CssBaseline />
<NavBar /> <NavBar />
<Container maxWidth={false} disableGutters> <Container
id="appMainContainer"
maxWidth={false}
disableGutters
style={{ paddingTop: '64px' }}
>
<Switch> <Switch>
<Route path="/sources/:sourceId/search/"> <Route path="/sources/:sourceId/search/">
<Search /> <Search />
@@ -76,8 +88,8 @@ export default function App() {
<Route path="/sources"> <Route path="/sources">
<Sources /> <Sources />
</Route> </Route>
<Route path="/manga/:mangaId/chapter/:chapterId"> <Route path="/manga/:mangaId/chapter/:chapterNum">
<Reader /> <></>
</Route> </Route>
<Route path="/manga/:id"> <Route path="/manga/:id">
<Manga /> <Manga />
@@ -102,7 +114,12 @@ export default function App() {
/> />
</Switch> </Switch>
</Container> </Container>
</NavBarTitle.Provider> <Switch>
<Route path="/manga/:mangaId/chapter/:chapterIndex">
<Reader />
</Route>
</Switch>
</NavbarContext.Provider>
</ThemeProvider> </ThemeProvider>
</Router> </Router>
); );
+16 -7
View File
@@ -12,6 +12,7 @@ import Dialog from '@material-ui/core/Dialog';
import Checkbox from '@material-ui/core/Checkbox'; import Checkbox from '@material-ui/core/Checkbox';
import FormControlLabel from '@material-ui/core/FormControlLabel'; import FormControlLabel from '@material-ui/core/FormControlLabel';
import FormGroup from '@material-ui/core/FormGroup'; import FormGroup from '@material-ui/core/FormGroup';
import client from '../util/client';
const useStyles = makeStyles(() => createStyles({ const useStyles = makeStyles(() => createStyles({
paper: { paper: {
@@ -41,14 +42,14 @@ export default function CategorySelect(props: IProps) {
useEffect(() => { useEffect(() => {
let tmpCategoryInfos: ICategoryInfo[] = []; let tmpCategoryInfos: ICategoryInfo[] = [];
fetch('http://127.0.0.1:4567/api/v1/category/') client.get('/api/v1/category/')
.then((response) => response.json()) .then((response) => response.data)
.then((data: ICategory[]) => { .then((data: ICategory[]) => {
tmpCategoryInfos = data.map((category) => ({ category, selected: false })); tmpCategoryInfos = data.map((category) => ({ category, selected: false }));
}) })
.then(() => { .then(() => {
fetch(`http://127.0.0.1:4567/api/v1/manga/${mangaId}/category/`) client.get(`/api/v1/manga/${mangaId}/category/`)
.then((response) => response.json()) .then((response) => response.data)
.then((data: ICategory[]) => { .then((data: ICategory[]) => {
data.forEach((category) => { data.forEach((category) => {
tmpCategoryInfos[category.order - 1].selected = true; tmpCategoryInfos[category.order - 1].selected = true;
@@ -69,9 +70,9 @@ export default function CategorySelect(props: IProps) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleChange = (event: React.ChangeEvent<HTMLInputElement>, categoryId: number) => { const handleChange = (event: React.ChangeEvent<HTMLInputElement>, categoryId: number) => {
const { checked } = event.target as HTMLInputElement; const { checked } = event.target as HTMLInputElement;
fetch(`http://127.0.0.1:4567/api/v1/manga/${mangaId}/category/${categoryId}`, {
method: checked ? 'GET' : 'DELETE', mode: 'cors', const method = checked ? client.get : client.delete;
}) method(`/api/v1/manga/${mangaId}/category/${categoryId}`)
.then(() => triggerUpdate()); .then(() => triggerUpdate());
}; };
@@ -84,6 +85,14 @@ export default function CategorySelect(props: IProps) {
<DialogTitle>Set categories</DialogTitle> <DialogTitle>Set categories</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<FormGroup> <FormGroup>
{categoryInfos.length === 0
&& (
<span>
No categories found!
<br />
You should make some from settings.
</span>
)}
{categoryInfos.map((categoryInfo) => ( {categoryInfos.map((categoryInfo) => (
<FormControlLabel <FormControlLabel
control={( control={(
+16 -3
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* This Source Code Form is subject to the terms of the Mozilla Public /* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
@@ -8,6 +9,7 @@ import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent'; import CardContent from '@material-ui/core/CardContent';
import Button from '@material-ui/core/Button'; import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography'; import Typography from '@material-ui/core/Typography';
import { Link, useHistory } from 'react-router-dom';
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { root: {
@@ -41,6 +43,7 @@ interface IProps{
export default function ChapterCard(props: IProps) { export default function ChapterCard(props: IProps) {
const classes = useStyles(); const classes = useStyles();
const history = useHistory();
const { chapter } = props; const { chapter } = props;
const dateStr = chapter.date_upload && new Date(chapter.date_upload).toISOString().slice(0, 10); const dateStr = chapter.date_upload && new Date(chapter.date_upload).toISOString().slice(0, 10);
@@ -63,9 +66,19 @@ export default function ChapterCard(props: IProps) {
</Typography> </Typography>
</div> </div>
</div> </div>
<div style={{ display: 'flex' }}> <Link
<Button variant="outlined" style={{ marginLeft: 20 }} onClick={() => { window.location.href = `/manga/${chapter.mangaId}/chapter/${chapter.id}`; }}>open</Button> to={`/manga/${chapter.mangaId}/chapter/${chapter.chapterIndex}`}
</div> style={{ textDecoration: 'none' }}
>
<Button
variant="outlined"
style={{ marginLeft: 20 }}
>
open
</Button>
</Link>
</CardContent> </CardContent>
</Card> </Card>
</li> </li>
+17 -7
View File
@@ -9,6 +9,8 @@ import CardContent from '@material-ui/core/CardContent';
import Button from '@material-ui/core/Button'; import Button from '@material-ui/core/Button';
import Avatar from '@material-ui/core/Avatar'; import Avatar from '@material-ui/core/Avatar';
import Typography from '@material-ui/core/Typography'; import Typography from '@material-ui/core/Typography';
import client from '../util/client';
import useLocalStorage from '../util/useLocalStorage';
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { root: {
@@ -38,6 +40,7 @@ const useStyles = makeStyles((theme) => ({
interface IProps { interface IProps {
extension: IExtension extension: IExtension
notifyInstall: () => void
} }
export default function ExtensionCard(props: IProps) { export default function ExtensionCard(props: IProps) {
@@ -45,24 +48,31 @@ export default function ExtensionCard(props: IProps) {
extension: { extension: {
name, lang, versionName, installed, apkName, iconUrl, name, lang, versionName, installed, apkName, iconUrl,
}, },
notifyInstall,
} = props; } = props;
const [installedState, setInstalledState] = useState<string>((installed ? 'uninstall' : 'install')); const [installedState, setInstalledState] = useState<string>((installed ? 'uninstall' : 'install'));
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
const classes = useStyles(); const classes = useStyles();
const langPress = lang === 'all' ? 'All' : lang.toUpperCase(); const langPress = lang === 'all' ? 'All' : lang.toUpperCase();
function install() { function install() {
setInstalledState('installing'); setInstalledState('installing');
fetch(`http://127.0.0.1:4567/api/v1/extension/install/${apkName}`).then(() => { client.get(`/api/v1/extension/install/${apkName}`)
setInstalledState('uninstall'); .then(() => {
}); setInstalledState('uninstall');
notifyInstall();
});
} }
function uninstall() { function uninstall() {
setInstalledState('uninstalling'); setInstalledState('uninstalling');
fetch(`http://127.0.0.1:4567/api/v1/extension/uninstall/${apkName}`).then(() => { client.get(`/api/v1/extension/uninstall/${apkName}`)
setInstalledState('install'); .then(() => {
}); // setInstalledState('install');
notifyInstall();
});
} }
function handleButtonClick() { function handleButtonClick() {
@@ -81,7 +91,7 @@ export default function ExtensionCard(props: IProps) {
variant="rounded" variant="rounded"
className={classes.icon} className={classes.icon}
alt={name} alt={name}
src={iconUrl} src={serverAddress + iconUrl}
/> />
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" component="h2"> <Typography variant="h5" component="h2">
@@ -0,0 +1,105 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import React, { useState } from 'react';
import { makeStyles, createStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import DialogTitle from '@material-ui/core/DialogTitle';
import DialogContent from '@material-ui/core/DialogContent';
import DialogActions from '@material-ui/core/DialogActions';
import Dialog from '@material-ui/core/Dialog';
import Switch from '@material-ui/core/Switch';
import IconButton from '@material-ui/core/IconButton';
import FilterListIcon from '@material-ui/icons/FilterList';
import { List, ListItemSecondaryAction, ListItemText } from '@material-ui/core';
import ListItem from '@material-ui/core/ListItem';
import { langCodeToName } from '../util/language';
const useStyles = makeStyles(() => createStyles({
paper: {
maxHeight: 435,
width: '80%',
},
}));
interface IProps {
shownLangs: string[]
setShownLangs: (arg0: string[]) => void
allLangs: string[]
}
export default function ExtensionLangSelect(props: IProps) {
const { shownLangs, setShownLangs, allLangs } = props;
// hold a copy and only sate state on parent when OK pressed, improves performance
const [mShownLangs, setMShownLangs] = useState(shownLangs);
const classes = useStyles();
const [open, setOpen] = useState<boolean>(false);
const handleCancel = () => {
setOpen(false);
};
const handleOk = () => {
setOpen(false);
setShownLangs(mShownLangs);
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>, lang: string) => {
const { checked } = event.target as HTMLInputElement;
if (checked) {
setMShownLangs([...mShownLangs, lang]);
} else {
const clone = JSON.parse(JSON.stringify(mShownLangs));
clone.splice(clone.indexOf(lang), 1);
setMShownLangs(clone);
}
};
return (
<>
<IconButton
onClick={() => setOpen(true)}
aria-label="display more actions"
edge="end"
color="inherit"
>
<FilterListIcon />
</IconButton>
<Dialog
classes={classes}
maxWidth="xs"
open={open}
>
<DialogTitle>Enabled Languages</DialogTitle>
<DialogContent dividers style={{ padding: 0 }}>
<List>
{allLangs.map((lang) => (
<ListItem key={lang}>
<ListItemText primary={langCodeToName(lang)} />
<ListItemSecondaryAction>
<Switch
checked={mShownLangs.indexOf(lang) !== -1}
onChange={(e) => handleChange(e, lang)}
/>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
</DialogContent>
<DialogActions>
<Button autoFocus onClick={handleCancel} color="primary">
Cancel
</Button>
<Button onClick={handleOk} color="primary">
Ok
</Button>
</DialogActions>
</Dialog>
</>
);
}
+4 -2
View File
@@ -10,6 +10,7 @@ import CardMedia from '@material-ui/core/CardMedia';
import Typography from '@material-ui/core/Typography'; import Typography from '@material-ui/core/Typography';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Grid } from '@material-ui/core'; import { Grid } from '@material-ui/core';
import useLocalStorage from '../util/useLocalStorage';
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
@@ -42,7 +43,7 @@ const useStyles = makeStyles({
}); });
interface IProps { interface IProps {
manga: IManga manga: IMangaCard
} }
const MangaCard = React.forwardRef((props: IProps, ref) => { const MangaCard = React.forwardRef((props: IProps, ref) => {
const { const {
@@ -51,6 +52,7 @@ const MangaCard = React.forwardRef((props: IProps, ref) => {
}, },
} = props; } = props;
const classes = useStyles(); const classes = useStyles();
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
return ( return (
<Grid item xs={6} sm={4} md={3} lg={2}> <Grid item xs={6} sm={4} md={3} lg={2}>
@@ -62,7 +64,7 @@ const MangaCard = React.forwardRef((props: IProps, ref) => {
className={classes.image} className={classes.image}
component="img" component="img"
alt={title} alt={title}
image={thumbnailUrl} image={serverAddress + thumbnailUrl}
title={title} title={title}
/> />
<div className={classes.gradient} /> <div className={classes.gradient} />
+202 -27
View File
@@ -2,16 +2,108 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import { Button, createStyles, makeStyles } from '@material-ui/core'; import { makeStyles } from '@material-ui/core';
import React, { useState } from 'react'; import IconButton from '@material-ui/core/IconButton';
import { Theme } from '@material-ui/core/styles';
import FavoriteIcon from '@material-ui/icons/Favorite';
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder';
import FilterListIcon from '@material-ui/icons/FilterList';
import PublicIcon from '@material-ui/icons/Public';
import React, { useContext, useEffect, useState } from 'react';
import NavbarContext from '../context/NavbarContext';
import client from '../util/client';
import useLocalStorage from '../util/useLocalStorage';
import CategorySelect from './CategorySelect'; import CategorySelect from './CategorySelect';
const useStyles = makeStyles(() => createStyles({ const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({
root: { root: {
width: '100%',
[theme.breakpoints.up('md')]: {
position: 'fixed',
width: '50vw',
},
},
top: {
padding: '10px',
// [theme.breakpoints.up('md')]: {
// minWidth: '50%',
// },
},
leftRight: {
display: 'flex', display: 'flex',
flexDirection: 'row-reverse', },
leftSide: {
'& img': {
borderRadius: 4,
maxWidth: '100%',
minWidth: '100%',
height: 'auto',
},
maxWidth: '50%',
// [theme.breakpoints.up('md')]: {
// minWidth: '100px',
// },
},
rightSide: {
marginLeft: 15,
maxWidth: '100%',
'& span': {
fontWeight: '400',
},
[theme.breakpoints.up('lg')]: {
fontSize: '1.3em',
},
},
buttons: {
display: 'flex',
justifyContent: 'space-around',
'& button': { '& button': {
marginLeft: 10, color: inLibrary === 'In Library' ? '#2196f3' : 'inherit',
},
'& span': {
display: 'block',
fontSize: '0.85em',
},
'& a': {
textDecoration: 'none',
color: '#858585',
'& button': {
color: 'inherit',
},
},
},
bottom: {
paddingLeft: '10px',
paddingRight: '10px',
[theme.breakpoints.up('md')]: {
fontSize: '1.2em',
// maxWidth: '50%',
},
[theme.breakpoints.up('lg')]: {
fontSize: '1.3em',
},
},
description: {
'& h4': {
marginTop: '1em',
marginBottom: 0,
},
'& p': {
textAlign: 'justify',
textJustify: 'inter-word',
},
},
genre: {
display: 'flex',
flexWrap: 'wrap',
'& h5': {
border: '2px solid #2196f3',
borderRadius: '1.13em',
marginRight: '1em',
marginTop: 0,
marginBottom: '10px',
padding: '0.3em',
color: '#2196f3',
}, },
}, },
})); }));
@@ -20,30 +112,70 @@ interface IProps{
manga: IManga manga: IManga
} }
function getSourceName(source: ISource) {
if (source.name !== null) {
return `${source.name} (${source.lang.toLocaleUpperCase()})`;
}
return source.id;
}
function getValueOrUnknown(val: string) {
return val || 'UNKNOWN';
}
export default function MangaDetails(props: IProps) { export default function MangaDetails(props: IProps) {
const classes = useStyles(); const { setAction } = useContext(NavbarContext);
const { manga } = props; const { manga } = props;
const [inLibrary, setInLibrary] = useState<string>( const [inLibrary, setInLibrary] = useState<string>(
manga.inLibrary ? 'In Library' : 'Not In Library', manga.inLibrary ? 'In Library' : 'Add to Library',
); );
const [categoryDialogOpen, setCategoryDialogOpen] = useState<boolean>(false); const [categoryDialogOpen, setCategoryDialogOpen] = useState<boolean>(false);
useEffect(() => {
if (inLibrary === 'In Library') {
setAction(
<>
<IconButton
onClick={() => setCategoryDialogOpen(true)}
aria-label="display more actions"
edge="end"
color="inherit"
>
<FilterListIcon />
</IconButton>
<CategorySelect
open={categoryDialogOpen}
setOpen={setCategoryDialogOpen}
mangaId={manga.id}
/>
</>,
);
} else { setAction(<></>); }
}, [inLibrary, categoryDialogOpen]);
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
const classes = useStyles(inLibrary)();
function addToLibrary() { function addToLibrary() {
setInLibrary('adding'); // setInLibrary('adding');
fetch(`http://127.0.0.1:4567/api/v1/manga/${manga.id}/library/`).then(() => { client.get(`/api/v1/manga/${manga.id}/library/`).then(() => {
setInLibrary('In Library'); setInLibrary('In Library');
}); });
} }
function removeFromLibrary() { function removeFromLibrary() {
setInLibrary('removing'); // setInLibrary('removing');
fetch(`http://127.0.0.1:4567/api/v1/manga/${manga.id}/library/`, { method: 'DELETE', mode: 'cors' }).then(() => { client.delete(`/api/v1/manga/${manga.id}/library/`).then(() => {
setInLibrary('Not In Library'); setInLibrary('Add To Library');
}); });
} }
function handleButtonClick() { function handleButtonClick() {
if (inLibrary === 'Not In Library') { if (inLibrary === 'Add To Library') {
addToLibrary(); addToLibrary();
} else { } else {
removeFromLibrary(); removeFromLibrary();
@@ -51,21 +183,64 @@ export default function MangaDetails(props: IProps) {
} }
return ( return (
<div> <div className={classes.root}>
<h1> <div className={classes.top}>
{manga && manga.title} <div className={classes.leftRight}>
</h1> <div className={classes.leftSide}>
<div className={classes.root}> <img src={serverAddress + manga.thumbnailUrl} alt="Manga Thumbnail" />
<Button variant="outlined" onClick={() => handleButtonClick()}>{inLibrary}</Button> </div>
{inLibrary === 'In Library' <div className={classes.rightSide}>
&& <Button variant="outlined" onClick={() => setCategoryDialogOpen(true)}>Edit Categories</Button>} <h1>
{manga.title}
</h1>
<h3>
Author:
{' '}
<span>{getValueOrUnknown(manga.author)}</span>
</h3>
<h3>
Artist:
{' '}
<span>{getValueOrUnknown(manga.artist)}</span>
</h3>
<h3>
Status:
{' '}
{manga.status}
</h3>
<h3>
Source:
{' '}
{getSourceName(manga.source)}
</h3>
</div>
</div>
<div className={classes.buttons}>
<div>
<IconButton onClick={() => handleButtonClick()}>
{inLibrary === 'In Library' && <FavoriteIcon />}
{inLibrary !== 'In Library' && <FavoriteBorderIcon />}
<span>{inLibrary}</span>
</IconButton>
</div>
{ /* eslint-disable-next-line react/jsx-no-target-blank */ }
<a href={manga.url} target="_blank">
<IconButton>
<PublicIcon />
<span>Open Site</span>
</IconButton>
</a>
</div>
</div>
<div className={classes.bottom}>
<div className={classes.description}>
<h4>About</h4>
<p>{manga.description}</p>
</div>
<div className={classes.genre}>
{manga.genre.split(', ').map((g) => <h5 key={g}>{g}</h5>)}
</div>
</div> </div>
<CategorySelect
open={categoryDialogOpen}
setOpen={setCategoryDialogOpen}
mangaId={manga.id}
/>
</div> </div>
); );
} }
+2 -2
View File
@@ -7,7 +7,7 @@ import Grid from '@material-ui/core/Grid';
import MangaCard from './MangaCard'; import MangaCard from './MangaCard';
interface IProps{ interface IProps{
mangas: IManga[] mangas: IMangaCard[]
message?: string message?: string
hasNextPage: boolean hasNextPage: boolean
lastPageNum: number lastPageNum: number
@@ -48,7 +48,7 @@ export default function MangaGrid(props: IProps) {
} }
return ( return (
<Grid container spacing={1} xs={12} style={{ margin: 0, padding: '5px' }}> <Grid container spacing={1} style={{ margin: 0, width: '100%', padding: '5px' }}>
{mapped} {mapped}
</Grid> </Grid>
); );
+29 -82
View File
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// TODO: remove above! // TODO: remove above!
/* This Source Code Form is subject to the terms of the Mozilla Public /* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -6,18 +5,14 @@
import React, { useContext, useState } from 'react'; import React, { useContext, useState } from 'react';
import { makeStyles } from '@material-ui/core/styles'; import { makeStyles } from '@material-ui/core/styles';
import MoreIcon from '@material-ui/icons/MoreVert';
import AppBar from '@material-ui/core/AppBar'; import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar'; import Toolbar from '@material-ui/core/Toolbar';
import Typography from '@material-ui/core/Typography'; import Typography from '@material-ui/core/Typography';
import IconButton from '@material-ui/core/IconButton'; import IconButton from '@material-ui/core/IconButton';
import MenuIcon from '@material-ui/icons/Menu'; import MenuIcon from '@material-ui/icons/Menu';
import MenuItem from '@material-ui/core/MenuItem'; import NavBarContext from '../context/NavbarContext';
import Menu from '@material-ui/core/Menu';
import TemporaryDrawer from './TemporaryDrawer';
import NavBarTitle from '../context/NavbarTitle';
import DarkTheme from '../context/DarkTheme'; import DarkTheme from '../context/DarkTheme';
import TemporaryDrawer from './TemporaryDrawer';
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { root: {
@@ -31,88 +26,40 @@ const useStyles = makeStyles((theme) => ({
}, },
})); }));
// const theme = createMuiTheme({
// overrides: {
// MuiAppBar: {
// colorPrimary: { backgroundColor: '#FFC0CB' },
// },
// },
// palette: { type: 'dark' },
// });
export default function NavBar() { export default function NavBar() {
const classes = useStyles(); const classes = useStyles();
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null); const { title, action, override } = useContext(NavBarContext);
const { title } = useContext(NavBarTitle);
const open = Boolean(anchorEl);
const { darkTheme } = useContext(DarkTheme); const { darkTheme } = useContext(DarkTheme);
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return ( return (
<div className={classes.root}> <>
<AppBar position="static" color={darkTheme ? 'default' : 'primary'}> {override.status && override.value}
<Toolbar> {!override.status
<IconButton && (
edge="start" <div className={classes.root}>
className={classes.menuButton} <AppBar position="fixed" color={darkTheme ? 'default' : 'primary'}>
color="inherit" <Toolbar>
aria-label="menu" <IconButton
disableRipple edge="start"
onClick={() => setDrawerOpen(true)} className={classes.menuButton}
> color="inherit"
<MenuIcon /> aria-label="menu"
</IconButton> disableRipple
<Typography variant="h6" className={classes.title}> onClick={() => setDrawerOpen(true)}
{title}
</Typography>
{/* <IconButton
onClick={handleMenu}
aria-label="display more actions"
edge="end"
color="inherit"
>
<MoreIcon />
</IconButton> */}
{/* <Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={open}
onClose={handleClose}
>
<MenuItem
onClick={() => { setDarkTheme(true); handleClose(); }}
> >
Dark Theme <MenuIcon />
</IconButton>
</MenuItem> <Typography variant="h6" className={classes.title}>
<MenuItem {title}
onClick={() => { setDarkTheme(false); handleClose(); }} </Typography>
> {action}
Light Theme </Toolbar>
</AppBar>
</MenuItem> <TemporaryDrawer drawerOpen={drawerOpen} setDrawerOpen={setDrawerOpen} />
</Menu> */} </div>
</Toolbar> )}
</AppBar> </>
<TemporaryDrawer drawerOpen={drawerOpen} setDrawerOpen={setDrawerOpen} />
</div>
); );
} }
+110
View File
@@ -0,0 +1,110 @@
/* eslint-disable react/no-unused-prop-types */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import CircularProgress from '@material-ui/core/CircularProgress';
import { makeStyles } from '@material-ui/core/styles';
import React, { useEffect, useRef, useState } from 'react';
import LazyLoad from 'react-lazyload';
import { IReaderSettings } from './ReaderNavBar';
const useStyles = (settings: IReaderSettings) => makeStyles({
loading: {
margin: '100px auto',
height: '100vh',
},
loadingImage: {
padding: settings.staticNav ? 'calc(50vh - 40px) calc(50vw - 340px)' : 'calc(50vh - 40px) calc(50vw - 40px)',
height: '100vh',
width: '200px',
backgroundColor: '#525252',
marginBottom: 10,
},
});
interface IProps {
src: string
index: number
setCurPage: React.Dispatch<React.SetStateAction<number>>
settings: IReaderSettings
}
function LazyImage(props: IProps) {
const {
src, index, setCurPage, settings,
} = props;
const classes = useStyles(settings)();
const [imageSrc, setImagsrc] = useState<string>('');
const ref = useRef<HTMLImageElement>(null);
const handleScroll = () => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
if (rect.y < 0 && rect.y + rect.height > 0) {
setCurPage(index);
}
}
};
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [handleScroll]);
useEffect(() => {
const img = new Image();
img.src = src;
img.onload = () => setImagsrc(src);
}, [src]);
if (imageSrc.length === 0) {
return (
<div className={classes.loadingImage}>
<CircularProgress thickness={5} />
</div>
);
}
return (
<img
ref={ref}
src={imageSrc}
alt={`Page #${index}`}
style={{ width: '100%' }}
/>
);
}
export default function Page(props: IProps) {
const {
src, index, setCurPage, settings,
} = props;
const classes = useStyles(settings)();
return (
<div style={{ margin: '0 auto' }}>
<LazyLoad
offset={window.innerHeight}
placeholder={(
<div className={classes.loading}>
<CircularProgress thickness={5} />
</div>
)}
>
<LazyImage
src={src}
index={index}
setCurPage={setCurPage}
settings={settings}
/>
</LazyLoad>
</div>
);
}
+347
View File
@@ -0,0 +1,347 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import IconButton from '@material-ui/core/IconButton';
import CloseIcon from '@material-ui/icons/Close';
import KeyboardArrowLeftIcon from '@material-ui/icons/KeyboardArrowLeft';
import KeyboardArrowRightIcon from '@material-ui/icons/KeyboardArrowRight';
import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp';
import { makeStyles, Theme, useTheme } from '@material-ui/core/styles';
import React, { useContext, useEffect, useState } from 'react';
import Typography from '@material-ui/core/Typography';
import { useHistory, Link } from 'react-router-dom';
import Slide from '@material-ui/core/Slide';
import Fade from '@material-ui/core/Fade';
import Zoom from '@material-ui/core/Zoom';
import { Switch } from '@material-ui/core';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
import Collapse from '@material-ui/core/Collapse';
import Button from '@material-ui/core/Button';
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
import DarkTheme from '../context/DarkTheme';
import NavBarContext from '../context/NavbarContext';
const useStyles = (settings: IReaderSettings) => makeStyles((theme: Theme) => ({
// main container and root div need to change classes...
AppMainContainer: {
display: 'none',
},
AppRootElment: {
display: 'flex',
},
root: {
position: settings.staticNav ? 'sticky' : 'fixed',
top: 0,
left: 0,
minWidth: '300px',
height: '100vh',
overflowY: 'auto',
backgroundColor: '#0a0b0b',
'& header': {
backgroundColor: '#363b3d',
display: 'flex',
alignItems: 'center',
minHeight: '64px',
paddingLeft: '24px',
paddingRight: '24px',
transition: 'left 2s ease',
'& button': {
flexGrow: 0,
flexShrink: 0,
},
'& button:nth-child(1)': {
marginRight: '16px',
},
'& button:nth-child(3)': {
marginRight: '-12px',
},
'& h1': {
fontSize: '1.25rem',
flexGrow: 1,
},
},
'& hr': {
margin: '0 16px',
height: '1px',
border: '0',
backgroundColor: 'rgb(38, 41, 43)',
},
},
navigation: {
margin: '0 16px',
'& > span:nth-child(1)': {
textAlign: 'center',
display: 'block',
marginTop: '16px',
},
'& $navigationChapters': {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gridTemplateAreas: '"prev next"',
gridColumnGap: '5px',
margin: '10px 0',
'& a': {
flexGrow: 1,
textDecoration: 'none',
'& button': {
width: '100%',
padding: '5px 8px',
textTransform: 'none',
},
},
},
},
navigationChapters: {}, // dummy rule
settingsCollapsseHeader: {
'& span': {
fontWeight: 'bold',
},
},
openDrawerButton: {
position: 'fixed',
top: 0 + 20,
left: 10 + 20,
height: '40px',
width: '40px',
borderRadius: 5,
backgroundColor: 'black',
'&:hover': {
backgroundColor: 'black',
},
},
}));
export interface IReaderSettings{
staticNav: boolean
showPageNumber: boolean
}
export const defaultReaderSettings = () => ({
staticNav: false,
showPageNumber: true,
} as IReaderSettings);
interface IProps {
settings: IReaderSettings
setSettings: React.Dispatch<React.SetStateAction<IReaderSettings>>
manga: IManga | IMangaCard
chapter: IChapter | IPartialChpter
curPage: number
}
export default function ReaderNavBar(props: IProps) {
const { title } = useContext(NavBarContext);
const { darkTheme } = useContext(DarkTheme);
const history = useHistory();
const {
settings, setSettings, manga, chapter, curPage,
} = props;
const [drawerOpen, setDrawerOpen] = useState(false || settings.staticNav);
const [drawerVisible, setDrawerVisible] = useState(false || settings.staticNav);
const [hideOpenButton, setHideOpenButton] = useState(false);
const [prevScrollPos, setPrevScrollPos] = useState(0);
const [settingsCollapseOpen, setSettingsCollapseOpen] = useState(false);
const theme = useTheme();
const classes = useStyles(settings)();
const setSettingValue = (key: string, value: any) => setSettings({ ...settings, [key]: value });
const handleScroll = () => {
const currentScrollPos = window.pageYOffset;
if (Math.abs(currentScrollPos - prevScrollPos) > 20) {
setHideOpenButton(currentScrollPos > prevScrollPos);
setPrevScrollPos(currentScrollPos);
}
};
useEffect(() => {
window.addEventListener('scroll', handleScroll);
const rootEl = document.querySelector('#root')!;
const mainContainer = document.querySelector('#appMainContainer')!;
rootEl.classList.add(classes.AppRootElment);
mainContainer.classList.add(classes.AppMainContainer);
return () => {
rootEl.classList.remove(classes.AppRootElment);
mainContainer.classList.remove(classes.AppMainContainer);
window.removeEventListener('scroll', handleScroll);
};
}, [handleScroll]);// handleScroll changes on every render
return (
<>
<ClickAwayListener onClickAway={() => (drawerVisible && setDrawerOpen(false))}>
<Slide
direction="right"
in={drawerOpen}
timeout={200}
appear={false}
mountOnEnter
unmountOnExit
onEntered={() => setDrawerVisible(true)}
onExited={() => setDrawerVisible(false)}
>
<div className={classes.root}>
<header>
<IconButton
edge="start"
color="inherit"
aria-label="menu"
disableRipple
onClick={() => history.push(`/manga/${manga.id}`)}
>
<CloseIcon />
</IconButton>
<Typography variant="h1">
{title}
</Typography>
{!settings.staticNav
&& (
<IconButton
edge="start"
color="inherit"
aria-label="menu"
disableRipple
onClick={() => setDrawerOpen(false)}
>
<KeyboardArrowLeftIcon />
</IconButton>
) }
</header>
<ListItem ContainerComponent="div" className={classes.settingsCollapsseHeader}>
<ListItemText primary="Reader Settings" />
<ListItemSecondaryAction>
<IconButton
edge="start"
color="inherit"
aria-label="menu"
disableRipple
disableFocusRipple
onClick={() => setSettingsCollapseOpen(!settingsCollapseOpen)}
>
{settingsCollapseOpen && <KeyboardArrowUpIcon />}
{!settingsCollapseOpen && <KeyboardArrowDownIcon />}
</IconButton>
</ListItemSecondaryAction>
</ListItem>
<Collapse in={settingsCollapseOpen} timeout="auto" unmountOnExit>
<List>
<ListItem>
<ListItemText primary="Static Navigation" />
<ListItemSecondaryAction>
<Switch
edge="end"
checked={settings.staticNav}
onChange={(e) => setSettingValue('staticNav', e.target.checked)}
/>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemText primary="Show page number" />
<ListItemSecondaryAction>
<Switch
edge="end"
checked={settings.showPageNumber}
onChange={(e) => setSettingValue('showPageNumber', e.target.checked)}
/>
</ListItemSecondaryAction>
</ListItem>
</List>
</Collapse>
<hr />
<div className={classes.navigation}>
<span>
Currently on page
{' '}
{curPage + 1}
{' '}
of
{' '}
{chapter.pageCount}
</span>
<div className={classes.navigationChapters}>
{chapter.chapterIndex > 1
&& (
<Link
style={{ gridArea: 'prev' }}
to={`/manga/${manga.id}/chapter/${chapter.chapterIndex - 1}`}
>
<Button
variant="outlined"
startIcon={<KeyboardArrowLeftIcon />}
>
Chapter
{' '}
{chapter.chapterIndex - 1}
</Button>
</Link>
)}
{chapter.chapterIndex < chapter.chapterCount
&& (
<Link
style={{ gridArea: 'next' }}
to={`/manga/${manga.id}/chapter/${chapter.chapterIndex + 1}`}
>
<Button
variant="outlined"
endIcon={<KeyboardArrowRightIcon />}
>
Chapter
{' '}
{chapter.chapterIndex + 1}
</Button>
</Link>
)}
</div>
</div>
</div>
</Slide>
</ClickAwayListener>
<Zoom in={!drawerOpen}>
<Fade in={!hideOpenButton}>
<IconButton
className={classes.openDrawerButton}
edge="start"
color="inherit"
aria-label="menu"
disableRipple
disableFocusRipple
onClick={() => setDrawerOpen(true)}
>
<KeyboardArrowRightIcon />
</IconButton>
</Fade>
</Zoom>
</>
);
}
+6 -2
View File
@@ -9,6 +9,8 @@ import CardContent from '@material-ui/core/CardContent';
import Button from '@material-ui/core/Button'; import Button from '@material-ui/core/Button';
import Avatar from '@material-ui/core/Avatar'; import Avatar from '@material-ui/core/Avatar';
import Typography from '@material-ui/core/Typography'; import Typography from '@material-ui/core/Typography';
import useLocalStorage from '../util/useLocalStorage';
import { langCodeToName } from '../util/language';
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { root: {
@@ -47,6 +49,8 @@ export default function SourceCard(props: IProps) {
}, },
} = props; } = props;
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
const classes = useStyles(); const classes = useStyles();
return ( return (
@@ -57,14 +61,14 @@ export default function SourceCard(props: IProps) {
variant="rounded" variant="rounded"
className={classes.icon} className={classes.icon}
alt={name} alt={name}
src={iconUrl} src={serverAddress + iconUrl}
/> />
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" component="h2"> <Typography variant="h5" component="h2">
{name} {name}
</Typography> </Typography>
<Typography variant="caption" display="block" gutterBottom> <Typography variant="caption" display="block" gutterBottom>
{lang} {langCodeToName(lang)}
</Typography> </Typography>
</div> </div>
</div> </div>
+41 -55
View File
@@ -27,68 +27,54 @@ interface IProps {
export default function TemporaryDrawer({ drawerOpen, setDrawerOpen }: IProps) { export default function TemporaryDrawer({ drawerOpen, setDrawerOpen }: IProps) {
const classes = useStyles(); const classes = useStyles();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const sideList = (side: 'left') => (
<div
className={classes.list}
role="presentation"
onClick={() => setDrawerOpen(false)}
onKeyDown={() => setDrawerOpen(false)}
>
<List>
<Link to="/library" style={{ color: 'inherit', textDecoration: 'none' }}>
<ListItem button key="Library">
<ListItemIcon>
<InboxIcon />
</ListItemIcon>
<ListItemText primary="Library" />
</ListItem>
</Link>
<Link to="/extensions" style={{ color: 'inherit', textDecoration: 'none' }}>
<ListItem button key="Extensions">
<ListItemIcon>
<InboxIcon />
</ListItemIcon>
<ListItemText primary="Extensions" />
</ListItem>
</Link>
<Link to="/sources" style={{ color: 'inherit', textDecoration: 'none' }}>
<ListItem button key="Sources">
<ListItemIcon>
<InboxIcon />
</ListItemIcon>
<ListItemText primary="Sources" />
</ListItem>
</Link>
<Link to="/settings" style={{ color: 'inherit', textDecoration: 'none' }}>
<ListItem button key="settings">
<ListItemIcon>
<InboxIcon />
</ListItemIcon>
<ListItemText primary="Settings" />
</ListItem>
</Link>
{/* <Link to="/search" style={{ color: 'inherit', textDecoration: 'none' }}>
<ListItem button key="Search">
<ListItemIcon>
<InboxIcon />
</ListItemIcon>
<ListItemText primary="Global Search" />
</ListItem>
</Link> */}
</List>
</div>
);
return ( return (
<div> <div>
<Drawer <Drawer
BackdropProps={{ invisible: true }}
open={drawerOpen} open={drawerOpen}
anchor="left" anchor="left"
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
> >
{sideList('left')} <div
className={classes.list}
role="presentation"
onClick={() => setDrawerOpen(false)}
onKeyDown={() => setDrawerOpen(false)}
>
<List>
<Link to="/library" style={{ color: 'inherit', textDecoration: 'none' }}>
<ListItem button key="Library">
<ListItemIcon>
<InboxIcon />
</ListItemIcon>
<ListItemText primary="Library" />
</ListItem>
</Link>
<Link to="/extensions" style={{ color: 'inherit', textDecoration: 'none' }}>
<ListItem button key="Extensions">
<ListItemIcon>
<InboxIcon />
</ListItemIcon>
<ListItemText primary="Extensions" />
</ListItem>
</Link>
<Link to="/sources" style={{ color: 'inherit', textDecoration: 'none' }}>
<ListItem button key="Sources">
<ListItemIcon>
<InboxIcon />
</ListItemIcon>
<ListItemText primary="Sources" />
</ListItem>
</Link>
<Link to="/settings" style={{ color: 'inherit', textDecoration: 'none' }}>
<ListItem button key="settings">
<ListItemIcon>
<InboxIcon />
</ListItemIcon>
<ListItemText primary="Settings" />
</ListItem>
</Link>
</List>
</div>
</Drawer> </Drawer>
</div> </div>
); );
@@ -7,11 +7,19 @@ import React from 'react';
type ContextType = { type ContextType = {
title: string title: string
setTitle: React.Dispatch<React.SetStateAction<string>> setTitle: React.Dispatch<React.SetStateAction<string>>
action: any
setAction: React.Dispatch<React.SetStateAction<any>>
override: INavbarOverride
setOverride: React.Dispatch<React.SetStateAction<INavbarOverride>>
}; };
const NavBarTitle = React.createContext<ContextType>({ const NavBarContext = React.createContext<ContextType>({
title: 'Tachidesk', title: 'Tachidesk',
setTitle: ():void => {}, setTitle: ():void => {},
action: <div />,
setAction: ():void => {},
override: { status: false, value: <div /> },
setOverride: ():void => {},
}); });
export default NavBarTitle; export default NavBarContext;
-6
View File
@@ -5,7 +5,6 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import App from './App'; import App from './App';
import reportWebVitals from './reportWebVitals';
import './index.css'; import './index.css';
// roboto font // roboto font
import 'fontsource-roboto'; import 'fontsource-roboto';
@@ -16,8 +15,3 @@ ReactDOM.render(
</React.StrictMode>, </React.StrictMode>,
document.getElementById('root'), document.getElementById('root'),
); );
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
-21
View File
@@ -1,21 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry) {
import('web-vitals').then(({
getCLS, getFID, getFCP, getLCP, getTTFB,
}) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+91 -11
View File
@@ -4,21 +4,101 @@
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import ExtensionCard from '../components/ExtensionCard'; import ExtensionCard from '../components/ExtensionCard';
import NavBarTitle from '../context/NavbarTitle'; import NavbarContext from '../context/NavbarContext';
import client from '../util/client';
import useLocalStorage from '../util/useLocalStorage';
import ExtensionLangSelect from '../components/ExtensionLangSelect';
import { defualtLangs, langCodeToName, langSortCmp } from '../util/language';
const allLangs: string[] = [];
function groupExtensions(extensions: IExtension[]) {
allLangs.length = 0; // empty the array
const result = { installed: [] } as any;
extensions.sort((a, b) => ((a.apkName > b.apkName) ? 1 : -1));
extensions.forEach((extension) => {
if (result[extension.lang] === undefined) {
result[extension.lang] = [];
if (extension.lang !== 'all') { allLangs.push(extension.lang); }
}
if (extension.installed) {
result.installed.push(extension);
} else {
result[extension.lang].push(extension);
}
});
// put english first for convience
allLangs.sort(langSortCmp);
return result;
}
function extensionDefaultLangs() {
return [...defualtLangs(), 'all'];
}
export default function Extensions() { export default function Extensions() {
const { setTitle } = useContext(NavBarTitle); const { setTitle, setAction } = useContext(NavbarContext);
setTitle('Extensions'); const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownExtensionLangs', extensionDefaultLangs());
const [extensions, setExtensions] = useState<IExtension[]>([]);
useEffect(() => { useEffect(() => {
fetch('http://127.0.0.1:4567/api/v1/extension/list') setTitle('Extensions');
.then((response) => response.json()) setAction(
.then((data) => setExtensions(data)); <ExtensionLangSelect
}, []); shownLangs={shownLangs}
setShownLangs={setShownLangs}
allLangs={allLangs}
/>,
);
}, [shownLangs]);
if (extensions.length === 0) { const [extensionsRaw, setExtensionsRaw] = useState<IExtension[]>([]);
return <h3>wait</h3>; const [extensions, setExtensions] = useState<any>({});
const [updateTriggerHolder, setUpdateTriggerHolder] = useState(0); // just a hack
const triggerUpdate = () => setUpdateTriggerHolder(updateTriggerHolder + 1); // just a hack
useEffect(() => {
client.get('/api/v1/extension/list')
.then((response) => response.data)
.then((data) => setExtensionsRaw(data));
}, [updateTriggerHolder]);
useEffect(() => {
if (extensionsRaw.length > 0) {
const groupedExtension = groupExtensions(extensionsRaw);
setExtensions(groupedExtension);
}
}, [extensionsRaw]);
if (Object.entries(extensions).length === 0) {
return <h3>loading...</h3>;
} }
return <>{extensions.map((it) => <ExtensionCard extension={it} />)}</>; return (
<>
{
Object.entries(extensions).map(([lang, list]) => (
((['installed', ...shownLangs].indexOf(lang) !== -1 && (list as []).length > 0)
&& (
<React.Fragment key={lang}>
<h1 key={lang} style={{ marginLeft: 25 }}>
{langCodeToName(lang)}
</h1>
{(list as IExtension[]).map((it) => (
<ExtensionCard
key={it.apkName}
extension={it}
notifyInstall={() => {
triggerUpdate();
}}
/>
))}
</React.Fragment>
))
))
}
</>
);
} }
+56 -56
View File
@@ -5,11 +5,13 @@
import { Tab, Tabs } from '@material-ui/core'; import { Tab, Tabs } from '@material-ui/core';
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import MangaGrid from '../components/MangaGrid'; import MangaGrid from '../components/MangaGrid';
import NavBarTitle from '../context/NavbarTitle'; import NavbarContext from '../context/NavbarContext';
import client from '../util/client';
interface IMangaCategory { interface IMangaCategory {
category: ICategory category: ICategory
mangas: IManga[] mangas: IManga[]
isFetched: boolean
} }
interface TabPanelProps { interface TabPanelProps {
@@ -35,78 +37,74 @@ function TabPanel(props: TabPanelProps) {
} }
export default function Library() { export default function Library() {
const { setTitle } = useContext(NavBarTitle); const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => { setTitle('Library'); setAction(<></>); }, []);
const [tabs, setTabs] = useState<IMangaCategory[]>([]); const [tabs, setTabs] = useState<IMangaCategory[]>([]);
const [tabNum, setTabNum] = useState<number>(0); const [tabNum, setTabNum] = useState<number>(0);
// a hack so MangaGrid doesn't stop working. I won't change it in case // a hack so MangaGrid doesn't stop working. I won't change it in case
// if I do manga pagination for library.. // if I do manga pagination for library..
const [lastPageNum, setLastPageNum] = useState<number>(1); const [lastPageNum, setLastPageNum] = useState<number>(1);
useEffect(() => {
setTitle('Library');
}, []);
// eslint-disable-next-line @typescript-eslint/no-shadow
const fetchAndSetMangas = (tabs: IMangaCategory[], tab: IMangaCategory, index: number) => {
fetch(`http://127.0.0.1:4567/api/v1/category/${tab.category.id}`)
.then((response) => response.json())
.then((data: IManga[]) => {
const tabsClone = JSON.parse(JSON.stringify(tabs));
tabsClone[index].mangas = data;
setTabs(tabsClone); // clone the object
});
};
const handleTabChange = (newTab: number) => { const handleTabChange = (newTab: number) => {
setTabNum(newTab); setTabNum(newTab);
tabs.forEach((tab, index) => {
if (tab.category.order === newTab && tab.mangas.length === 0) {
// mangas are empty, fetch the mangas
fetchAndSetMangas(tabs, tab, index);
}
});
}; };
useEffect(() => { useEffect(() => {
fetch('http://127.0.0.1:4567/api/v1/library') Promise.all<IManga[], ICategory[]>([
.then((response) => response.json()) client.get('/api/v1/library').then((response) => response.data),
.then((data: IManga[]) => { client.get('/api/v1/category').then((response) => response.data),
// if some manga with no category exist, they will be added under a virtual category ])
if (data.length > 0) {
return [
{
category: {
name: 'Default', isLanding: true, order: 0, id: -1,
},
mangas: data,
},
]; // will set state on the next fetch
}
// no default category so the first tab is 1
setTabNum(1);
return [];
})
.then( .then(
(newTabs: IMangaCategory[]) => { ([libraryMangas, categories]) => {
fetch('http://127.0.0.1:4567/api/v1/category') const categoryTabs = categories.map((category) => ({
.then((response) => response.json()) category,
.then((data: ICategory[]) => { mangas: [] as IManga[],
const mangaCategories = data.map((category) => ({ isFetched: false,
category, }));
mangas: [] as IManga[],
}));
const newNewTabs = [...newTabs, ...mangaCategories];
setTabs(newNewTabs);
// if no default category, we must fetch the first tab now... if (libraryMangas.length > 0 || categoryTabs.length === 0) {
// eslint-disable-next-line max-len const defaultCategoryTab = {
if (newTabs.length === 0) { fetchAndSetMangas(newNewTabs, newNewTabs[0], 0); } category: {
}); name: 'Default',
isLanding: true,
order: 0,
id: -1,
},
mangas: libraryMangas,
isFetched: true,
};
setTabs(
[defaultCategoryTab, ...categoryTabs],
);
} else {
setTabs(categoryTabs);
setTabNum(1);
}
}, },
); );
}, []); }, []);
// console.log(client.defaults.baseURL);
// fetch the current tab
useEffect(() => {
tabs.forEach((tab, index) => {
if (tab.category.order === tabNum && !tab.isFetched) {
// eslint-disable-next-line @typescript-eslint/no-shadow
client.get(`/api/v1/category/${tab.category.id}`)
.then((response) => response.data)
.then((data: IManga[]) => {
const tabsClone = JSON.parse(JSON.stringify(tabs));
tabsClone[index].mangas = data;
tabsClone[index].isFetched = true;
setTabs(tabsClone); // clone the object
});
}
});
}, [tabNum]);
let toRender; let toRender;
if (tabs.length > 1) { if (tabs.length > 1) {
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
@@ -119,11 +117,12 @@ export default function Library() {
hasNextPage={false} hasNextPage={false}
lastPageNum={lastPageNum} lastPageNum={lastPageNum}
setLastPageNum={setLastPageNum} setLastPageNum={setLastPageNum}
message={tab.isFetched ? 'Category is Empty' : 'Loading...'}
/> />
</TabPanel> </TabPanel>
)); ));
// 160px is min-width for viewport width of >600 // Visual Hack: 160px is min-width for viewport width of >600
const scrollableTabs = window.innerWidth < tabs.length * 160; const scrollableTabs = window.innerWidth < tabs.length * 160;
toRender = ( toRender = (
<> <>
@@ -149,6 +148,7 @@ export default function Library() {
hasNextPage={false} hasNextPage={false}
lastPageNum={lastPageNum} lastPageNum={lastPageNum}
setLastPageNum={setLastPageNum} setLastPageNum={setLastPageNum}
message={tabs.length > 0 ? 'Library is Empty' : undefined}
/> />
); );
} }
+49 -12
View File
@@ -1,23 +1,54 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* This Source Code Form is subject to the terms of the Mozilla Public /* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import React, { useEffect, useState, useContext } from 'react'; import React, { useEffect, useState, useContext } from 'react';
import { makeStyles, Theme } from '@material-ui/core/styles';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import CircularProgress from '@material-ui/core/CircularProgress';
import ChapterCard from '../components/ChapterCard'; import ChapterCard from '../components/ChapterCard';
import MangaDetails from '../components/MangaDetails'; import MangaDetails from '../components/MangaDetails';
import NavBarTitle from '../context/NavbarTitle'; import NavbarContext from '../context/NavbarContext';
import client from '../util/client';
const useStyles = makeStyles((theme: Theme) => ({
root: {
[theme.breakpoints.up('md')]: {
display: 'flex',
},
},
chapters: {
listStyle: 'none',
padding: 0,
[theme.breakpoints.up('md')]: {
width: '50%',
marginLeft: '50%',
},
},
loading: {
margin: '10px 0',
display: 'flex',
justifyContent: 'center',
},
}));
export default function Manga() { export default function Manga() {
const classes = useStyles();
const { setTitle } = useContext(NavbarContext);
useEffect(() => { setTitle('Manga'); }, []); // delegate setting topbar action to MangaDetails
const { id } = useParams<{id: string}>(); const { id } = useParams<{id: string}>();
const { setTitle } = useContext(NavBarTitle);
const [manga, setManga] = useState<IManga>(); const [manga, setManga] = useState<IManga>();
const [chapters, setChapters] = useState<IChapter[]>([]); const [chapters, setChapters] = useState<IChapter[]>([]);
useEffect(() => { useEffect(() => {
fetch(`http://127.0.0.1:4567/api/v1/manga/${id}/`) client.get(`/api/v1/manga/${id}/`)
.then((response) => response.json()) .then((response) => response.data)
.then((data: IManga) => { .then((data: IManga) => {
setManga(data); setManga(data);
setTitle(data.title); setTitle(data.title);
@@ -25,21 +56,27 @@ export default function Manga() {
}, []); }, []);
useEffect(() => { useEffect(() => {
fetch(`http://127.0.0.1:4567/api/v1/manga/${id}/chapters`) client.get(`/api/v1/manga/${id}/chapters`)
.then((response) => response.json()) .then((response) => response.data)
.then((data) => setChapters(data)); .then((data) => setChapters(data));
}, []); }, []);
const chapterCards = chapters.map((chapter) => ( const chapterCards = (
<ol style={{ listStyle: 'none', padding: 0 }}> <ol className={classes.chapters}>
<ChapterCard chapter={chapter} /> {chapters.length === 0
&& (
<div className={classes.loading}>
<CircularProgress thickness={5} />
</div>
) }
{chapters.map((chapter) => (<ChapterCard chapter={chapter} />))}
</ol> </ol>
)); );
return ( return (
<> <div className={classes.root}>
{manga && <MangaDetails manga={manga} />} {manga && <MangaDetails manga={manga} />}
{chapterCards} {chapterCards}
</> </div>
); );
} }
+94 -28
View File
@@ -1,52 +1,118 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* This Source Code Form is subject to the terms of the Mozilla Public /* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import CircularProgress from '@material-ui/core/CircularProgress';
import { makeStyles } from '@material-ui/core/styles';
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import NavBarTitle from '../context/NavbarTitle'; import Page from '../components/Page';
import ReaderNavBar, { defaultReaderSettings, IReaderSettings } from '../components/ReaderNavBar';
import NavbarContext from '../context/NavbarContext';
import client from '../util/client';
import useLocalStorage from '../util/useLocalStorage';
const style = { const useStyles = (settings: IReaderSettings) => makeStyles({
display: 'flex', reader: {
flexDirection: 'column', display: 'flex',
justifyContent: 'center', flexDirection: 'column',
margin: '0 auto', justifyContent: 'center',
backgroundColor: '#343a40', margin: '0 auto',
} as React.CSSProperties; },
loading: {
margin: '50px auto',
},
pageNumber: {
display: settings.showPageNumber ? 'block' : 'none',
position: 'fixed',
bottom: '50px',
right: settings.staticNav ? 'calc((100vw - 325px)/2)' : 'calc((100vw - 25px)/2)',
width: '50px',
textAlign: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
borderRadius: '10px',
},
});
const range = (n:number) => Array.from({ length: n }, (value, key) => key); const range = (n:number) => Array.from({ length: n }, (value, key) => key);
const initialChapter = () => ({ pageCount: -1, chapterIndex: -1, chapterCount: 0 });
export default function Reader() { export default function Reader() {
const { setTitle } = useContext(NavBarTitle); const [settings, setSettings] = useLocalStorage<IReaderSettings>('readerSettings', defaultReaderSettings);
const [pageCount, setPageCount] = useState<number>(-1); const classes = useStyles(settings)();
const { chapterId, mangaId } = useParams<{chapterId: string, mangaId: string}>();
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
const { chapterIndex, mangaId } = useParams<{chapterIndex: string, mangaId: string}>();
const [manga, setManga] = useState<IMangaCard | IManga>({ id: +mangaId, title: '', thumbnailUrl: '' });
const [chapter, setChapter] = useState<IChapter | IPartialChpter>(initialChapter());
const [curPage, setCurPage] = useState<number>(0);
const { setOverride, setTitle } = useContext(NavbarContext);
useEffect(() => {
setOverride(
{
status: true,
value: (
<ReaderNavBar
settings={settings}
setSettings={setSettings}
manga={manga}
chapter={chapter}
curPage={curPage}
/>
),
},
);
// clean up for when we leave the reader
return () => setOverride({ status: false, value: <div /> });
}, [manga, chapter, settings, curPage, chapterIndex]);
useEffect(() => { useEffect(() => {
fetch(`http://127.0.0.1:4567/api/v1/manga/${mangaId}/chapter/${chapterId}`) setTitle('Reader');
.then((response) => response.json()) client.get(`/api/v1/manga/${mangaId}/`)
.then((data:IChapter) => { .then((response) => response.data)
setTitle(data.name); .then((data: IManga) => {
setPageCount(data.pageCount); setManga(data);
setTitle(data.title);
}); });
}, []); }, [chapterIndex]);
if (pageCount === -1) { useEffect(() => {
setChapter(initialChapter);
client.get(`/api/v1/manga/${mangaId}/chapter/${chapterIndex}`)
.then((response) => response.data)
.then((data:IChapter) => {
setChapter(data);
});
}, [chapterIndex]);
if (chapter.pageCount === -1) {
return ( return (
<div style={style}> <div className={classes.loading}>
<h3>wait</h3> <CircularProgress thickness={5} />
</div> </div>
); );
} }
const mapped = range(pageCount).map((index) => (
<div style={{ margin: '0 auto' }}>
<img src={`http://127.0.0.1:4567/api/v1/manga/${mangaId}/chapter/${chapterId}/page/${index}`} alt="f" style={{ maxWidth: '100%' }} />
</div>
));
return ( return (
<div style={style}> <div className={classes.reader}>
{mapped} <div className={classes.pageNumber}>
{`${curPage + 1} / ${chapter.pageCount}`}
</div>
{range(chapter.pageCount).map((index) => (
<Page
key={index}
index={index}
src={`${serverAddress}/api/v1/manga/${mangaId}/chapter/${chapterIndex}/page/${index}`}
setCurPage={setCurPage}
settings={settings}
/>
))}
</div> </div>
); );
} }
+10 -7
View File
@@ -8,7 +8,8 @@ import TextField from '@material-ui/core/TextField';
import Button from '@material-ui/core/Button'; import Button from '@material-ui/core/Button';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import MangaGrid from '../components/MangaGrid'; import MangaGrid from '../components/MangaGrid';
import NavBarTitle from '../context/NavbarTitle'; import NavbarContext from '../context/NavbarContext';
import client from '../util/client';
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { root: {
@@ -20,11 +21,13 @@ const useStyles = makeStyles((theme) => ({
})); }));
export default function SearchSingle() { export default function SearchSingle() {
const { setTitle } = useContext(NavBarTitle); const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => { setTitle('Search'); setAction(<></>); }, []);
const { sourceId } = useParams<{sourceId: string}>(); const { sourceId } = useParams<{sourceId: string}>();
const classes = useStyles(); const classes = useStyles();
const [error, setError] = useState<boolean>(false); const [error, setError] = useState<boolean>(false);
const [mangas, setMangas] = useState<IManga[]>([]); const [mangas, setMangas] = useState<IMangaCard[]>([]);
const [message, setMessage] = useState<string>(''); const [message, setMessage] = useState<string>('');
const [searchTerm, setSearchTerm] = useState<string>(''); const [searchTerm, setSearchTerm] = useState<string>('');
const [hasNextPage, setHasNextPage] = useState<boolean>(false); const [hasNextPage, setHasNextPage] = useState<boolean>(false);
@@ -33,8 +36,8 @@ export default function SearchSingle() {
const textInput = React.createRef<HTMLInputElement>(); const textInput = React.createRef<HTMLInputElement>();
useEffect(() => { useEffect(() => {
fetch(`http://127.0.0.1:4567/api/v1/source/${sourceId}`) client.get(`/api/v1/source/${sourceId}`)
.then((response) => response.json()) .then((response) => response.data)
.then((data: { name: string }) => setTitle(`Search: ${data.name}`)); .then((data: { name: string }) => setTitle(`Search: ${data.name}`));
}, []); }, []);
@@ -54,8 +57,8 @@ export default function SearchSingle() {
useEffect(() => { useEffect(() => {
if (searchTerm.length > 0) { if (searchTerm.length > 0) {
fetch(`http://127.0.0.1:4567/api/v1/source/${sourceId}/search/${searchTerm}/${lastPageNum}`) client.get(`/api/v1/source/${sourceId}/search/${searchTerm}/${lastPageNum}`)
.then((response) => response.json()) .then((response) => response.data)
.then((data: { mangaList: IManga[], hasNextPage: boolean }) => { .then((data: { mangaList: IManga[], hasNextPage: boolean }) => {
if (data.mangaList.length > 0) { if (data.mangaList.length > 0) {
setMangas([ setMangas([
+76 -11
View File
@@ -2,16 +2,21 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import React, { useContext } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import List from '@material-ui/core/List'; import List from '@material-ui/core/List';
import ListItem, { ListItemProps } from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import InboxIcon from '@material-ui/icons/Inbox'; import InboxIcon from '@material-ui/icons/Inbox';
import Brightness6Icon from '@material-ui/icons/Brightness6'; import Brightness6Icon from '@material-ui/icons/Brightness6';
import { ListItemSecondaryAction, Switch } from '@material-ui/core'; import DnsIcon from '@material-ui/icons/Dns';
import NavBarTitle from '../context/NavbarTitle'; import EditIcon from '@material-ui/icons/Edit';
import {
Button, Dialog, DialogActions, DialogContent,
DialogContentText, IconButton, ListItemSecondaryAction, Switch, TextField,
ListItemIcon, ListItemText,
} from '@material-ui/core';
import ListItem, { ListItemProps } from '@material-ui/core/ListItem';
import NavbarContext from '../context/NavbarContext';
import DarkTheme from '../context/DarkTheme'; import DarkTheme from '../context/DarkTheme';
import useLocalStorage from '../util/useLocalStorage';
function ListItemLink(props: ListItemProps<'a', { button?: true }>) { function ListItemLink(props: ListItemProps<'a', { button?: true }>) {
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
@@ -19,13 +24,31 @@ function ListItemLink(props: ListItemProps<'a', { button?: true }>) {
} }
export default function Settings() { export default function Settings() {
const { setTitle } = useContext(NavBarTitle); const { setTitle, setAction } = useContext(NavbarContext);
setTitle('Settings'); useEffect(() => { setTitle('Settings'); setAction(<></>); }, []);
const { darkTheme, setDarkTheme } = useContext(DarkTheme); const { darkTheme, setDarkTheme } = useContext(DarkTheme);
const [serverAddress, setServerAddress] = useLocalStorage<String>('serverBaseURL', '');
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogValue, setDialogValue] = useState(serverAddress);
const handleDialogOpen = () => {
setDialogValue(serverAddress);
setDialogOpen(true);
};
const handleDialogCancel = () => {
setDialogOpen(false);
};
const handleDialogSubmit = () => {
setDialogOpen(false);
setServerAddress(dialogValue);
};
return ( return (
<div> <>
<List component="nav" style={{ padding: 0 }}> <List style={{ padding: 0 }}>
<ListItemLink href="/settings/categories"> <ListItemLink href="/settings/categories">
<ListItemIcon> <ListItemIcon>
<InboxIcon /> <InboxIcon />
@@ -45,7 +68,49 @@ export default function Settings() {
/> />
</ListItemSecondaryAction> </ListItemSecondaryAction>
</ListItem> </ListItem>
<ListItem>
<ListItemIcon>
<DnsIcon />
</ListItemIcon>
<ListItemText primary="Server Address" secondary={serverAddress} />
<ListItemSecondaryAction>
<IconButton
onClick={() => {
handleDialogOpen();
}}
>
<EditIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</List> </List>
</div>
<Dialog open={dialogOpen} onClose={handleDialogCancel}>
<DialogContent>
<DialogContentText>
Enter new category name.
</DialogContentText>
<TextField
autoFocus
margin="dense"
id="name"
label="Category Name"
type="text"
fullWidth
value={dialogValue}
onChange={(e) => setDialogValue(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleDialogCancel} color="primary">
Cancel
</Button>
<Button onClick={handleDialogSubmit} color="primary">
Set
</Button>
</DialogActions>
</Dialog>
</>
); );
} }
+10 -7
View File
@@ -5,25 +5,28 @@
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import MangaGrid from '../components/MangaGrid'; import MangaGrid from '../components/MangaGrid';
import NavBarTitle from '../context/NavbarTitle'; import NavbarContext from '../context/NavbarContext';
import client from '../util/client';
export default function SourceMangas(props: { popular: boolean }) { export default function SourceMangas(props: { popular: boolean }) {
const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => { setTitle('Source'); setAction(<></>); }, []);
const { sourceId } = useParams<{sourceId: string}>(); const { sourceId } = useParams<{sourceId: string}>();
const { setTitle } = useContext(NavBarTitle); const [mangas, setMangas] = useState<IMangaCard[]>([]);
const [mangas, setMangas] = useState<IManga[]>([]);
const [hasNextPage, setHasNextPage] = useState<boolean>(false); const [hasNextPage, setHasNextPage] = useState<boolean>(false);
const [lastPageNum, setLastPageNum] = useState<number>(1); const [lastPageNum, setLastPageNum] = useState<number>(1);
useEffect(() => { useEffect(() => {
fetch(`http://127.0.0.1:4567/api/v1/source/${sourceId}`) client.get(`/api/v1/source/${sourceId}`)
.then((response) => response.json()) .then((response) => response.data)
.then((data: { name: string }) => setTitle(data.name)); .then((data: { name: string }) => setTitle(data.name));
}, []); }, []);
useEffect(() => { useEffect(() => {
const sourceType = props.popular ? 'popular' : 'latest'; const sourceType = props.popular ? 'popular' : 'latest';
fetch(`http://127.0.0.1:4567/api/v1/source/${sourceId}/${sourceType}/${lastPageNum}`) client.get(`/api/v1/source/${sourceId}/${sourceType}/${lastPageNum}`)
.then((response) => response.json()) .then((response) => response.data)
.then((data: { mangaList: IManga[], hasNextPage: boolean }) => { .then((data: { mangaList: IManga[], hasNextPage: boolean }) => {
setMangas([ setMangas([
...mangas, ...mangas,
+65 -8
View File
@@ -3,22 +3,79 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import ExtensionLangSelect from '../components/ExtensionLangSelect';
import SourceCard from '../components/SourceCard'; import SourceCard from '../components/SourceCard';
import NavBarTitle from '../context/NavbarTitle'; import NavbarContext from '../context/NavbarContext';
import client from '../util/client';
import { defualtLangs, langCodeToName, langSortCmp } from '../util/language';
import useLocalStorage from '../util/useLocalStorage';
function sourceToLangList(sources: ISource[]) {
const result: string[] = [];
sources.forEach((source) => {
if (result.indexOf(source.lang) === -1) { result.push(source.lang); }
});
result.sort(langSortCmp);
return result;
}
function groupByLang(sources: ISource[]) {
const result = {} as any;
sources.forEach((source) => {
if (result[source.lang] === undefined) { result[source.lang] = [] as ISource[]; }
result[source.lang].push(source);
});
return result;
}
export default function Sources() { export default function Sources() {
const { setTitle } = useContext(NavBarTitle); const { setTitle, setAction } = useContext(NavbarContext);
setTitle('Sources');
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', defualtLangs());
const [sources, setSources] = useState<ISource[]>([]); const [sources, setSources] = useState<ISource[]>([]);
const [fetched, setFetched] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
fetch('http://127.0.0.1:4567/api/v1/source/list') setTitle('Sources');
.then((response) => response.json()) setAction(
.then((data) => setSources(data)); <ExtensionLangSelect
shownLangs={shownLangs}
setShownLangs={setShownLangs}
allLangs={sourceToLangList(sources)}
/>,
);
}, [shownLangs, sources]);
useEffect(() => {
client.get('/api/v1/source/list')
.then((response) => response.data)
.then((data) => { setSources(data); setFetched(true); });
}, []); }, []);
if (sources.length === 0) { if (sources.length === 0) {
return (<h3>wait</h3>); if (fetched) return (<h3>No sources found. Install Some Extensions first.</h3>);
return (<h3>loading...</h3>);
} }
return <>{sources.map((it) => <SourceCard source={it} />)}</>; return (
<>
{/* eslint-disable-next-line max-len */}
{Object.entries(groupByLang(sources)).sort((a, b) => langSortCmp(a[0], b[0])).map(([lang, list]) => (
shownLangs.indexOf(lang) !== -1 && (
<React.Fragment key={lang}>
<h1 key={lang} style={{ marginLeft: 25 }}>{langCodeToName(lang)}</h1>
{(list as ISource[]).map((source) => (
<SourceCard
key={source.id}
source={source}
/>
))}
</React.Fragment>
)
))}
</>
);
} }
+25 -34
View File
@@ -27,7 +27,8 @@ import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent'; import DialogContent from '@material-ui/core/DialogContent';
import DialogContentText from '@material-ui/core/DialogContentText'; import DialogContentText from '@material-ui/core/DialogContentText';
import DialogTitle from '@material-ui/core/DialogTitle'; import DialogTitle from '@material-ui/core/DialogTitle';
import NavBarTitle from '../../context/NavbarTitle'; import NavbarContext from '../../context/NavbarContext';
import client from '../../util/client';
const getItemStyle = (isDragging, draggableStyle, palette) => ({ const getItemStyle = (isDragging, draggableStyle, palette) => ({
// styles we need to apply on draggables // styles we need to apply on draggables
@@ -39,11 +40,12 @@ const getItemStyle = (isDragging, draggableStyle, palette) => ({
}); });
export default function Categories() { export default function Categories() {
const { setTitle } = useContext(NavBarTitle); const { setTitle, setAction } = useContext(NavbarContext);
setTitle('Categories'); useEffect(() => { setTitle('Categories'); setAction(<></>); }, []);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [categoryToEdit, setCategoryToEdit] = useState(-1); // -1 means new category const [categoryToEdit, setCategoryToEdit] = useState(-1); // -1 means new category
const [dialogOpen, setDialogOpen] = React.useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [dialogValue, setDialogValue] = useState(''); const [dialogValue, setDialogValue] = useState('');
const theme = useTheme(); const theme = useTheme();
@@ -52,8 +54,8 @@ export default function Categories() {
useEffect(() => { useEffect(() => {
if (!dialogOpen) { if (!dialogOpen) {
fetch('http://127.0.0.1:4567/api/v1/category/') client.get('/api/v1/category/')
.then((response) => response.json()) .then((response) => response.data)
.then((data) => setCategories(data)); .then((data) => setCategories(data));
} }
}, [updateTriggerHolder]); }, [updateTriggerHolder]);
@@ -64,11 +66,8 @@ export default function Categories() {
const formData = new FormData(); const formData = new FormData();
formData.append('from', from + 1); formData.append('from', from + 1);
formData.append('to', to + 1); formData.append('to', to + 1);
fetch(`http://127.0.0.1:4567/api/v1/category/${category.id}/reorder`, { client.post(`/api/v1/category/${category.id}/reorder`, formData)
method: 'PATCH', .finally(() => triggerUpdate());
mode: 'cors',
body: formData,
}).finally(() => triggerUpdate());
// also move it in local state to avoid jarring moving behviour... // also move it in local state to avoid jarring moving behviour...
const result = Array.from(list); const result = Array.from(list);
@@ -90,48 +89,40 @@ export default function Categories() {
)); ));
}; };
const handleDialogOpen = () => {
setDialogOpen(true);
};
const resetDialog = () => { const resetDialog = () => {
setDialogOpen(false);
setDialogValue(''); setDialogValue('');
setCategoryToEdit(-1); setCategoryToEdit(-1);
}; };
const handleDialogCancel = () => { const handleDialogOpen = () => {
resetDialog(); resetDialog();
setDialogOpen(true);
};
const handleDialogCancel = () => {
setDialogOpen(false);
}; };
const handleDialogSubmit = () => { const handleDialogSubmit = () => {
resetDialog(); setDialogOpen(false);
const formData = new FormData(); const formData = new FormData();
formData.append('name', dialogValue); formData.append('name', dialogValue);
if (categoryToEdit === -1) { if (categoryToEdit === -1) {
fetch('http://127.0.0.1:4567/api/v1/category/', { client.post('/api/v1/category/', formData)
method: 'POST', .finally(() => triggerUpdate());
mode: 'cors',
body: formData,
}).finally(() => triggerUpdate());
} else { } else {
const category = categories[categoryToEdit]; const category = categories[categoryToEdit];
fetch(`http://127.0.0.1:4567/api/v1/category/${category.id}`, { client.patch(`/api/v1/category/${category.id}`, formData)
method: 'PATCH', .finally(() => triggerUpdate());
mode: 'cors',
body: formData,
}).finally(() => triggerUpdate());
} }
}; };
const deleteCategory = (index) => { const deleteCategory = (index) => {
const category = categories[index]; const category = categories[index];
fetch(`http://127.0.0.1:4567/api/v1/category/${category.id}`, { client.delete(`/api/v1/category/${category.id}`)
method: 'DELETE', .finally(() => triggerUpdate());
mode: 'cors',
}).finally(() => triggerUpdate());
}; };
return ( return (
@@ -167,8 +158,8 @@ export default function Categories() {
/> />
<IconButton <IconButton
onClick={() => { onClick={() => {
setCategoryToEdit(index);
handleDialogOpen(); handleDialogOpen();
setCategoryToEdit(index);
}} }}
> >
<EditIcon /> <EditIcon />
@@ -201,7 +192,7 @@ export default function Categories() {
> >
<AddIcon /> <AddIcon />
</Fab> </Fab>
<Dialog open={dialogOpen} onClose={handleDialogCancel} aria-labelledby="form-dialog-title"> <Dialog open={dialogOpen} onClose={handleDialogCancel}>
<DialogTitle id="form-dialog-title"> <DialogTitle id="form-dialog-title">
{categoryToEdit === -1 ? 'New Catalog' : `Rename: ${categories[categoryToEdit].name}`} {categoryToEdit === -1 ? 'New Catalog' : `Rename: ${categories[categoryToEdit].name}`}
</DialogTitle> </DialogTitle>

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