Compare commits

..

24 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
69 changed files with 1175 additions and 290 deletions
+3 -3
View File
@@ -34,10 +34,10 @@ Note that the issue will be automatically closed if you do not fill out the titl
2. Second Step
### Expected behavior
Describe what should have happened
Describe what should have happened. Remove this line after you are done.
### Actual behavior
Describe what happens instead
Describe what happens instead. Remove this line after you are done.
## Other details
Describe additional details If necessary
Describe additional details If necessary. Remove this line after you are done.
+2 -2
View File
@@ -23,7 +23,7 @@ Note that the issue will be automatically closed if you do not fill out the titl
---
## What feature should be added to Tachidesk?
Explain What the feature is and how it should work in detail
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
Explain why this should be added. Remove this line after you are done.
+5
View File
@@ -28,5 +28,10 @@ jobs:
"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
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
uses: eskatos/gradle-command-action@v1
with:
@@ -1,4 +1,11 @@
#!/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..."
rm -rf "tmp"
mkdir -p "tmp"
+17 -11
View File
@@ -21,19 +21,25 @@ Here is a list of current features:
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
#### Prerequisites
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.
### 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 app
Download the latest jar or windows(win32) release from [the releases section](https://github.com/AriaMoradi/Tachidesk/releases).
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.
#### Running pre-built Windows packages
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.
### Windows
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.
## General troubleshooting
@@ -43,7 +49,7 @@ 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\Tachidesk`
On Windows 7 and later : `C:\Users\<Account>\AppData\Local\Tachidesk`
On Unix/Linux : `/home/<account>/.local/share/Tachidesk`
@@ -60,7 +66,7 @@ This project has two components:
#### Manual download
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)
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
Run `./gradlew shadowJar`, the resulting built jar file will be `server/build/Tachidesk-vX.Y.Z-rxxx.jar`.
### building the Windows package
@@ -76,7 +82,7 @@ How to do it is described in `webUI/react/README.md` but for short,
and supports HMR and all the other goodies you'll need.
## 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`.
+4 -4
View File
@@ -9,7 +9,7 @@ plugins {
id("edu.sc.seis.launch4j") version "2.4.9"
}
val TachideskVersion = "v0.2.4"
val TachideskVersion = "v0.2.6"
repositories {
@@ -63,7 +63,7 @@ dependencies {
val coroutinesVersion = "1.3.9"
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/"))
// api
@@ -88,8 +88,8 @@ dependencies {
implementation(project(":AndroidCompat:Config"))
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit")
// testImplementation("org.jetbrains.kotlin:kotlin-test")
// testImplementation("org.jetbrains.kotlin:kotlin-test-junit")
}
val name = "ir.armor.tachidesk.Main"
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.
@@ -58,6 +58,10 @@ class Main {
openInBrowser()
}
app.exception(NullPointerException::class.java) { _, ctx ->
ctx.status(404)
}
app.get("/api/v1/extension/list") { ctx ->
ctx.json(getExtensionList())
}
@@ -78,6 +82,7 @@ class Main {
ctx.status(200)
}
// icon for extension named `apkName`
app.get("/api/v1/extension/icon/:apkName") { ctx ->
val apkName = ctx.pathParam("apkName")
val result = getExtensionIcon(apkName)
@@ -86,31 +91,38 @@ class Main {
ctx.header("content-type", result.second)
}
// list of sources
app.get("/api/v1/source/list") { ctx ->
ctx.json(getSourceList())
}
// fetch source with id `sourceId`
app.get("/api/v1/source/:sourceId") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
ctx.json(getSource(sourceId))
}
// popular mangas from source with id `sourceId`
app.get("/api/v1/source/:sourceId/popular/:pageNum") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(getMangaList(sourceId, pageNum, popular = true))
}
// latest mangas from source with id `sourceId`
app.get("/api/v1/source/:sourceId/latest/:pageNum") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(getMangaList(sourceId, pageNum, popular = false))
}
// get manga info
app.get("/api/v1/manga/:mangaId/") { ctx ->
val mangaId = ctx.pathParam("mangaId").toInt()
ctx.json(getManga(mangaId))
}
// manga thumbnail
app.get("api/v1/manga/:mangaId/thumbnail") { ctx ->
val mangaId = ctx.pathParam("mangaId").toInt()
val result = getThumbnail(mangaId)
@@ -133,7 +145,7 @@ class Main {
ctx.status(200)
}
// adds the manga to category
// list manga's categories
app.get("api/v1/manga/:mangaId/category/") { ctx ->
val mangaId = ctx.pathParam("mangaId").toInt()
ctx.json(getMangaCategories(mangaId))
@@ -160,10 +172,10 @@ class Main {
ctx.json(getChapterList(mangaId))
}
app.get("/api/v1/manga/:mangaId/chapter/:chapterId") { ctx ->
val chapterId = ctx.pathParam("chapterId").toInt()
app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex") { ctx ->
val chapterIndex = ctx.pathParam("chapterIndex").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 ->
@@ -215,7 +227,7 @@ class Main {
// category modification
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 isLanding = if (ctx.formParam("isLanding") != null) ctx.formParam("isLanding")?.toBoolean() else null
updateCategory(categoryId, name, isLanding)
@@ -12,5 +12,7 @@ data class ChapterDataClass(
val chapter_number: Float,
val scanlator: String?,
val mangaId: Int,
val chapterIndex: Int,
val chapterCount: Int,
val pageCount: Int? = null,
)
@@ -8,7 +8,7 @@ import ir.armor.tachidesk.database.table.MangaStatus
data class MangaDataClass(
val id: Int,
val sourceId: Long,
val sourceId: String,
val url: String,
val title: String,
@@ -21,7 +21,8 @@ data class MangaDataClass(
val description: String? = null,
val genre: String? = null,
val status: String = MangaStatus.UNKNOWN.name,
val inLibrary: Boolean = false
val inLibrary: Boolean = false,
val source: SourceDataClass? = null
)
data class PagedMangaListDataClass(
@@ -6,8 +6,8 @@ package ir.armor.tachidesk.database.dataclass
data class SourceDataClass(
val id: String,
val name: String,
val lang: String,
val iconUrl: String,
val supportsLatest: Boolean
val name: String?,
val lang: String?,
val iconUrl: String?,
val supportsLatest: Boolean?
)
@@ -13,5 +13,7 @@ object ChapterTable : IntIdTable() {
val chapter_number = float("chapter_number").default(-1f)
val scanlator = varchar("scanlator", 128).nullable()
val chapterIndex = integer("number_in_list")
val manga = reference("manga", MangaTable)
}
@@ -28,13 +28,13 @@ object MangaTable : IntIdTable() {
val defaultCategory = bool("default_category").default(true)
// source is used by some ancestor of IntIdTable
val sourceReference = reference("source", SourceTable)
val sourceReference = long("source")
}
fun MangaTable.toDataClass(mangaEntry: ResultRow) =
MangaDataClass(
mangaEntry[MangaTable.id].value,
mangaEntry[sourceReference].value,
mangaEntry[sourceReference].toString(),
mangaEntry[MangaTable.url],
mangaEntry[MangaTable.title],
@@ -14,11 +14,13 @@ import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
fun getChapterList(mangaId: Int): List<ChapterDataClass> {
val mangaDetails = getManga(mangaId)
val source = getHttpSource(mangaDetails.sourceId)
val source = getHttpSource(mangaDetails.sourceId.toLong())
val chapterList = source.fetchChapterList(
SManga.create().apply {
@@ -27,8 +29,10 @@ fun getChapterList(mangaId: Int): List<ChapterDataClass> {
}
).toBlocking().first()
val chapterCount = chapterList.count()
return transaction {
chapterList.forEach { fetchedChapter ->
chapterList.reversed().forEachIndexed { index, fetchedChapter ->
val chapterEntry = ChapterTable.select { ChapterTable.url eq fetchedChapter.url }.firstOrNull()
if (chapterEntry == null) {
ChapterTable.insertAndGetId {
@@ -38,12 +42,29 @@ fun getChapterList(mangaId: Int): List<ChapterDataClass> {
it[chapter_number] = fetchedChapter.chapter_number
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
}
}
}
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(
ChapterTable.select { ChapterTable.url eq it.url }.firstOrNull()!![ChapterTable.id].value,
it.url,
@@ -51,18 +72,21 @@ fun getChapterList(mangaId: Int): List<ChapterDataClass> {
it.date_upload,
it.chapter_number,
it.scanlator,
mangaId
mangaId,
chapterCount - index,
chapterCount
)
}
}
}
fun getChapter(chapterId: Int, mangaId: Int): ChapterDataClass {
fun getChapter(chapterIndex: Int, mangaId: Int): ChapterDataClass {
return transaction {
val chapterEntry = ChapterTable.select { ChapterTable.id eq chapterId }.firstOrNull()!!
assert(mangaId == chapterEntry[ChapterTable.manga].value) // sanity check
val chapterEntry = ChapterTable.select {
ChapterTable.chapterIndex eq chapterIndex and (ChapterTable.manga 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(
SChapter.create().apply {
@@ -71,14 +95,20 @@ fun getChapter(chapterId: Int, mangaId: Int): ChapterDataClass {
}
).toBlocking().first()
val chapterId = chapterEntry[ChapterTable.id].value
val chapterCount = transaction { ChapterTable.selectAll().count() }
val chapter = ChapterDataClass(
chapterEntry[ChapterTable.id].value,
chapterId,
chapterEntry[ChapterTable.url],
chapterEntry[ChapterTable.name],
chapterEntry[ChapterTable.date_upload],
chapterEntry[ChapterTable.chapter_number],
chapterEntry[ChapterTable.scanlator],
mangaId,
chapterEntry[ChapterTable.chapterIndex],
chapterCount.toInt(),
pageList.count()
)
@@ -21,7 +21,7 @@ fun getManga(mangaId: Int, proxyThumbnail: Boolean = true): MangaDataClass {
return if (mangaEntry[MangaTable.initialized]) {
MangaDataClass(
mangaId,
mangaEntry[MangaTable.sourceReference].value,
mangaEntry[MangaTable.sourceReference].toString(),
mangaEntry[MangaTable.url],
mangaEntry[MangaTable.title],
@@ -34,10 +34,11 @@ fun getManga(mangaId: Int, proxyThumbnail: Boolean = true): MangaDataClass {
mangaEntry[MangaTable.description],
mangaEntry[MangaTable.genre],
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
mangaEntry[MangaTable.inLibrary]
mangaEntry[MangaTable.inLibrary],
getSource(mangaEntry[MangaTable.sourceReference])
)
} else { // initialize manga
val source = getHttpSource(mangaEntry[MangaTable.sourceReference].value)
val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
val fetchedManga = source.fetchMangaDetails(
SManga.create().apply {
url = mangaEntry[MangaTable.url]
@@ -65,7 +66,7 @@ fun getManga(mangaId: Int, proxyThumbnail: Boolean = true): MangaDataClass {
MangaDataClass(
mangaId,
mangaEntry[MangaTable.sourceReference].value,
mangaEntry[MangaTable.sourceReference].toString(),
mangaEntry[MangaTable.url],
mangaEntry[MangaTable.title],
@@ -78,7 +79,8 @@ fun getManga(mangaId: Int, proxyThumbnail: Boolean = true): MangaDataClass {
fetchedManga.description,
fetchedManga.genre,
MangaStatus.valueOf(fetchedManga.status).name,
false
false,
getSource(mangaEntry[MangaTable.sourceReference])
)
}
}
@@ -89,7 +91,7 @@ fun getThumbnail(mangaId: Int): Pair<InputStream, String> {
val fileName = mangaId.toString()
return getCachedResponse(saveDir, fileName) {
val sourceId = mangaEntry[MangaTable.sourceReference].value
val sourceId = mangaEntry[MangaTable.sourceReference]
val source = getHttpSource(sourceId)
var thumbnailUrl = mangaEntry[MangaTable.thumbnail_url]
if (thumbnailUrl == null || thumbnailUrl.isEmpty()) {
@@ -52,7 +52,7 @@ fun MangasPage.processEntries(sourceId: Long): PagedMangaListDataClass {
MangaDataClass(
mangaId,
sourceId,
sourceId.toString(),
manga.url,
manga.title,
@@ -70,7 +70,7 @@ fun MangasPage.processEntries(sourceId: Long): PagedMangaListDataClass {
val mangaId = mangaEntry[MangaTable.id].value
MangaDataClass(
mangaId,
sourceId,
sourceId.toString(),
manga.url,
manga.title,
@@ -27,7 +27,7 @@ fun getTrueImageUrl(page: Page, source: HttpSource): String {
fun getPageImage(mangaId: Int, chapterId: Int, index: Int): Pair<InputStream, String> {
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 pageEntry = transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq index) }.firstOrNull()!! }
@@ -56,7 +56,7 @@ fun getPageImage(mangaId: Int, chapterId: Int, index: Int): Pair<InputStream, St
fun getChapterDir(mangaId: Int, chapterId: Int): String {
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 sourceEntry = transaction { SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()!! }
val chapterEntry = transaction { ChapterTable.select { ChapterTable.id eq chapterId }.firstOrNull()!! }
@@ -18,6 +18,7 @@ fun sourceSearch(sourceId: Long, searchTerm: String, pageNum: Int): PagedMangaLi
}
fun sourceGlobalSearch(searchTerm: String) {
// TODO
}
data class FilterWrapper(
@@ -15,14 +15,18 @@ import ir.armor.tachidesk.database.table.SourceTable
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import java.lang.NullPointerException
import java.net.URL
import java.net.URLClassLoader
import java.util.Locale
private val sourceCache = mutableListOf<Pair<Long, HttpSource>>()
private val extensionCache = mutableListOf<Pair<String, Any>>()
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 }
if (cachedResult != null) {
println("used cached HttpSource: ${cachedResult.second.name}")
@@ -30,7 +34,6 @@ fun getHttpSource(sourceId: Long): HttpSource {
}
val result: HttpSource = transaction {
val sourceRecord = SourceEntity.findById(sourceId)!!
val extensionId = sourceRecord.extension.id.value
val extensionRecord = ExtensionEntity.findById(extensionId)!!
val apkName = extensionRecord.apkName
@@ -87,14 +90,14 @@ fun getSourceList(): List<SourceDataClass> {
fun getSource(sourceId: Long): SourceDataClass {
return transaction {
val source = SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()!!
val source = SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()
return@transaction SourceDataClass(
source[SourceTable.id].value.toString(),
source[SourceTable.name],
Locale(source[SourceTable.lang]).getDisplayLanguage(Locale(source[SourceTable.lang])),
ExtensionTable.select { ExtensionTable.id eq source[SourceTable.extension] }.first()[ExtensionTable.iconUrl],
getHttpSource(source[SourceTable.id].value).supportsLatest
sourceId.toString(),
source?.get(SourceTable.name),
source?.get(SourceTable.lang),
source?.let { ExtensionTable.select { ExtensionTable.id eq source[SourceTable.extension] }.first()[ExtensionTable.iconUrl] },
source?.let { getHttpSource(sourceId).supportsLatest }
)
}
}
@@ -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
@@ -8,11 +8,13 @@
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/react-lazyload": "^3.1.0",
"axios": "^0.21.1",
"fontsource-roboto": "^4.0.0",
"react": "^17.0.1",
"react-beautiful-dnd": "^13.0.0",
"react-dom": "^17.0.1",
"react-lazyload": "^3.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.1",
"web-vitals": "^0.2.4"
+17 -4
View File
@@ -27,9 +27,12 @@ import useLocalStorage from './util/useLocalStorage';
export default function App() {
const [title, setTitle] = useState<string>('Tachidesk');
const [action, setAction] = useState<any>(<div />);
const [override, setOverride] = useState<INavbarOverride>({ status: false, value: <div /> });
const [darkTheme, setDarkTheme] = useLocalStorage<boolean>('darkTheme', true);
const navBarContext = {
title, setTitle, action, setAction,
title, setTitle, action, setAction, override, setOverride,
};
const darkThemeContext = { darkTheme, setDarkTheme };
@@ -63,7 +66,12 @@ export default function App() {
<NavbarContext.Provider value={navBarContext}>
<CssBaseline />
<NavBar />
<Container maxWidth={false} disableGutters>
<Container
id="appMainContainer"
maxWidth={false}
disableGutters
style={{ paddingTop: '64px' }}
>
<Switch>
<Route path="/sources/:sourceId/search/">
<Search />
@@ -80,8 +88,8 @@ export default function App() {
<Route path="/sources">
<Sources />
</Route>
<Route path="/manga/:mangaId/chapter/:chapterId">
<Reader />
<Route path="/manga/:mangaId/chapter/:chapterNum">
<></>
</Route>
<Route path="/manga/:id">
<Manga />
@@ -106,6 +114,11 @@ export default function App() {
/>
</Switch>
</Container>
<Switch>
<Route path="/manga/:mangaId/chapter/:chapterIndex">
<Reader />
</Route>
</Switch>
</NavbarContext.Provider>
</ThemeProvider>
</Router>
@@ -85,6 +85,14 @@ export default function CategorySelect(props: IProps) {
<DialogTitle>Set categories</DialogTitle>
<DialogContent dividers>
<FormGroup>
{categoryInfos.length === 0
&& (
<span>
No categories found!
<br />
You should make some from settings.
</span>
)}
{categoryInfos.map((categoryInfo) => (
<FormControlLabel
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
* 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/. */
@@ -8,6 +9,7 @@ import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';
import { Link, useHistory } from 'react-router-dom';
const useStyles = makeStyles((theme) => ({
root: {
@@ -41,6 +43,7 @@ interface IProps{
export default function ChapterCard(props: IProps) {
const classes = useStyles();
const history = useHistory();
const { chapter } = props;
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>
</div>
</div>
<div style={{ display: 'flex' }}>
<Button variant="outlined" style={{ marginLeft: 20 }} onClick={() => { window.location.href = `/manga/${chapter.mangaId}/chapter/${chapter.id}`; }}>open</Button>
</div>
<Link
to={`/manga/${chapter.mangaId}/chapter/${chapter.chapterIndex}`}
style={{ textDecoration: 'none' }}
>
<Button
variant="outlined"
style={{ marginLeft: 20 }}
>
open
</Button>
</Link>
</CardContent>
</Card>
</li>
+1 -1
View File
@@ -43,7 +43,7 @@ const useStyles = makeStyles({
});
interface IProps {
manga: IManga
manga: IMangaCard
}
const MangaCard = React.forwardRef((props: IProps, ref) => {
const {
+199 -25
View File
@@ -2,17 +2,108 @@
* 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 { Button, createStyles, makeStyles } from '@material-ui/core';
import React, { useState } from 'react';
import { makeStyles } from '@material-ui/core';
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';
const useStyles = makeStyles(() => createStyles({
const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({
root: {
width: '100%',
[theme.breakpoints.up('md')]: {
position: 'fixed',
width: '50vw',
},
},
top: {
padding: '10px',
// [theme.breakpoints.up('md')]: {
// minWidth: '50%',
// },
},
leftRight: {
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': {
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',
},
},
}));
@@ -21,30 +112,70 @@ interface IProps{
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) {
const classes = useStyles();
const { setAction } = useContext(NavbarContext);
const { manga } = props;
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);
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() {
setInLibrary('adding');
// setInLibrary('adding');
client.get(`/api/v1/manga/${manga.id}/library/`).then(() => {
setInLibrary('In Library');
});
}
function removeFromLibrary() {
setInLibrary('removing');
// setInLibrary('removing');
client.delete(`/api/v1/manga/${manga.id}/library/`).then(() => {
setInLibrary('Not In Library');
setInLibrary('Add To Library');
});
}
function handleButtonClick() {
if (inLibrary === 'Not In Library') {
if (inLibrary === 'Add To Library') {
addToLibrary();
} else {
removeFromLibrary();
@@ -52,21 +183,64 @@ export default function MangaDetails(props: IProps) {
}
return (
<div>
<h1>
{manga && manga.title}
</h1>
<div className={classes.root}>
<Button variant="outlined" onClick={() => handleButtonClick()}>{inLibrary}</Button>
{inLibrary === 'In Library'
&& <Button variant="outlined" onClick={() => setCategoryDialogOpen(true)}>Edit Categories</Button>}
<div className={classes.root}>
<div className={classes.top}>
<div className={classes.leftRight}>
<div className={classes.leftSide}>
<img src={serverAddress + manga.thumbnailUrl} alt="Manga Thumbnail" />
</div>
<div className={classes.rightSide}>
<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>
<CategorySelect
open={categoryDialogOpen}
setOpen={setCategoryDialogOpen}
mangaId={manga.id}
/>
</div>
);
}
+2 -2
View File
@@ -7,7 +7,7 @@ import Grid from '@material-ui/core/Grid';
import MangaCard from './MangaCard';
interface IProps{
mangas: IManga[]
mangas: IMangaCard[]
message?: string
hasNextPage: boolean
lastPageNum: number
@@ -48,7 +48,7 @@ export default function MangaGrid(props: IProps) {
}
return (
<Grid container spacing={1} xs={12} style={{ margin: 0, padding: '5px' }}>
<Grid container spacing={1} style={{ margin: 0, width: '100%', padding: '5px' }}>
{mapped}
</Grid>
);
+28 -82
View File
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// TODO: remove above!
/* 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
@@ -6,18 +5,14 @@
import React, { useContext, useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import MoreIcon from '@material-ui/icons/MoreVert';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import Typography from '@material-ui/core/Typography';
import IconButton from '@material-ui/core/IconButton';
import MenuIcon from '@material-ui/icons/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import Menu from '@material-ui/core/Menu';
import TemporaryDrawer from './TemporaryDrawer';
import NavBarContext from '../context/NavbarContext';
import DarkTheme from '../context/DarkTheme';
import TemporaryDrawer from './TemporaryDrawer';
const useStyles = makeStyles((theme) => ({
root: {
@@ -31,89 +26,40 @@ const useStyles = makeStyles((theme) => ({
},
}));
// const theme = createMuiTheme({
// overrides: {
// MuiAppBar: {
// colorPrimary: { backgroundColor: '#FFC0CB' },
// },
// },
// palette: { type: 'dark' },
// });
export default function NavBar() {
const classes = useStyles();
const [drawerOpen, setDrawerOpen] = useState(false);
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const { title, action } = useContext(NavBarContext);
const open = Boolean(anchorEl);
const { title, action, override } = useContext(NavBarContext);
const { darkTheme } = useContext(DarkTheme);
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<div className={classes.root}>
<AppBar position="static" color={darkTheme ? 'default' : 'primary'}>
<Toolbar>
<IconButton
edge="start"
className={classes.menuButton}
color="inherit"
aria-label="menu"
disableRipple
onClick={() => setDrawerOpen(true)}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" className={classes.title}>
{title}
</Typography>
{action}
{/* <IconButton
onClick={handleMenu}
aria-label="display more actions"
edge="end"
color="inherit"
>
<FilterListIcon />
</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(); }}
<>
{override.status && override.value}
{!override.status
&& (
<div className={classes.root}>
<AppBar position="fixed" color={darkTheme ? 'default' : 'primary'}>
<Toolbar>
<IconButton
edge="start"
className={classes.menuButton}
color="inherit"
aria-label="menu"
disableRipple
onClick={() => setDrawerOpen(true)}
>
Dark Theme
</MenuItem>
<MenuItem
onClick={() => { setDarkTheme(false); handleClose(); }}
>
Light Theme
</MenuItem>
</Menu> */}
</Toolbar>
</AppBar>
<TemporaryDrawer drawerOpen={drawerOpen} setDrawerOpen={setDrawerOpen} />
</div>
<MenuIcon />
</IconButton>
<Typography variant="h6" className={classes.title}>
{title}
</Typography>
{action}
</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>
</>
);
}
+41 -55
View File
@@ -27,68 +27,54 @@ interface IProps {
export default function TemporaryDrawer({ drawerOpen, setDrawerOpen }: IProps) {
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 (
<div>
<Drawer
BackdropProps={{ invisible: true }}
open={drawerOpen}
anchor="left"
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>
</div>
);
@@ -9,6 +9,8 @@ type ContextType = {
setTitle: React.Dispatch<React.SetStateAction<string>>
action: any
setAction: React.Dispatch<React.SetStateAction<any>>
override: INavbarOverride
setOverride: React.Dispatch<React.SetStateAction<INavbarOverride>>
};
const NavBarContext = React.createContext<ContextType>({
@@ -16,6 +18,8 @@ const NavBarContext = React.createContext<ContextType>({
setTitle: ():void => {},
action: <div />,
setAction: ():void => {},
override: { status: false, value: <div /> },
setOverride: ():void => {},
});
export default NavBarContext;
+6 -2
View File
@@ -35,9 +35,13 @@ function groupExtensions(extensions: IExtension[]) {
return result;
}
function extensionDefaultLangs() {
return [...defualtLangs(), 'all'];
}
export default function Extensions() {
const { setTitle, setAction } = useContext(NavbarContext);
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownExtensionLangs', defualtLangs());
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownExtensionLangs', extensionDefaultLangs());
useEffect(() => {
setTitle('Extensions');
@@ -76,7 +80,7 @@ export default function Extensions() {
<>
{
Object.entries(extensions).map(([lang, list]) => (
(['installed', ...shownLangs].indexOf(lang) !== -1
((['installed', ...shownLangs].indexOf(lang) !== -1 && (list as []).length > 0)
&& (
<React.Fragment key={lang}>
<h1 key={lang} style={{ marginLeft: 25 }}>
+42 -8
View File
@@ -1,17 +1,45 @@
/* 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 React, { useEffect, useState, useContext } from 'react';
import { makeStyles, Theme } from '@material-ui/core/styles';
import { useParams } from 'react-router-dom';
import CircularProgress from '@material-ui/core/CircularProgress';
import ChapterCard from '../components/ChapterCard';
import MangaDetails from '../components/MangaDetails';
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() {
const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => { setTitle('Manga'); setAction(<></>); }, []);
const classes = useStyles();
const { setTitle } = useContext(NavbarContext);
useEffect(() => { setTitle('Manga'); }, []); // delegate setting topbar action to MangaDetails
const { id } = useParams<{id: string}>();
@@ -33,16 +61,22 @@ export default function Manga() {
.then((data) => setChapters(data));
}, []);
const chapterCards = chapters.map((chapter) => (
<ol style={{ listStyle: 'none', padding: 0 }}>
<ChapterCard chapter={chapter} />
const chapterCards = (
<ol className={classes.chapters}>
{chapters.length === 0
&& (
<div className={classes.loading}>
<CircularProgress thickness={5} />
</div>
) }
{chapters.map((chapter) => (<ChapterCard chapter={chapter} />))}
</ol>
));
);
return (
<>
<div className={classes.root}>
{manga && <MangaDetails manga={manga} />}
{chapterCards}
</>
</div>
);
}
+87 -26
View File
@@ -1,57 +1,118 @@
/* 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, { useContext, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
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 = {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
margin: '0 auto',
backgroundColor: '#343a40',
} as React.CSSProperties;
const useStyles = (settings: IReaderSettings) => makeStyles({
reader: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
margin: '0 auto',
},
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 initialChapter = () => ({ pageCount: -1, chapterIndex: -1, chapterCount: 0 });
export default function Reader() {
const { setTitle, setAction } = useContext(NavbarContext);
useEffect(() => { setTitle('Reader'); setAction(<></>); }, []);
const [settings, setSettings] = useLocalStorage<IReaderSettings>('readerSettings', defaultReaderSettings);
const classes = useStyles(settings)();
const [serverAddress] = useLocalStorage<String>('serverBaseURL', '');
const [pageCount, setPageCount] = useState<number>(-1);
const { chapterId, mangaId } = useParams<{chapterId: string, mangaId: string}>();
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(() => {
client.get(`/api/v1/manga/${mangaId}/chapter/${chapterId}`)
setTitle('Reader');
client.get(`/api/v1/manga/${mangaId}/`)
.then((response) => response.data)
.then((data: IManga) => {
setManga(data);
setTitle(data.title);
});
}, [chapterIndex]);
useEffect(() => {
setChapter(initialChapter);
client.get(`/api/v1/manga/${mangaId}/chapter/${chapterIndex}`)
.then((response) => response.data)
.then((data:IChapter) => {
setTitle(data.name);
setPageCount(data.pageCount);
setChapter(data);
});
}, []);
}, [chapterIndex]);
if (pageCount === -1) {
if (chapter.pageCount === -1) {
return (
<div style={style}>
<h3>wait</h3>
<div className={classes.loading}>
<CircularProgress thickness={5} />
</div>
);
}
const mapped = range(pageCount).map((index) => (
<div style={{ margin: '0 auto' }}>
<img src={`${serverAddress}/api/v1/manga/${mangaId}/chapter/${chapterId}/page/${index}`} alt="F" style={{ maxWidth: '100%' }} />
</div>
));
return (
<div style={style}>
{mapped}
<div className={classes.reader}>
<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>
);
}
+1 -1
View File
@@ -27,7 +27,7 @@ export default function SearchSingle() {
const { sourceId } = useParams<{sourceId: string}>();
const classes = useStyles();
const [error, setError] = useState<boolean>(false);
const [mangas, setMangas] = useState<IManga[]>([]);
const [mangas, setMangas] = useState<IMangaCard[]>([]);
const [message, setMessage] = useState<string>('');
const [searchTerm, setSearchTerm] = useState<string>('');
const [hasNextPage, setHasNextPage] = useState<boolean>(false);
+1 -1
View File
@@ -13,7 +13,7 @@ export default function SourceMangas(props: { popular: boolean }) {
useEffect(() => { setTitle('Source'); setAction(<></>); }, []);
const { sourceId } = useParams<{sourceId: string}>();
const [mangas, setMangas] = useState<IManga[]>([]);
const [mangas, setMangas] = useState<IMangaCard[]>([]);
const [hasNextPage, setHasNextPage] = useState<boolean>(false);
const [lastPageNum, setLastPageNum] = useState<number>(1);
+32 -2
View File
@@ -21,11 +21,28 @@ interface ISource {
history: any
}
interface IManga {
interface IMangaCard {
id: number
title: string
thumbnailUrl: string
inLibrary?: boolean
}
interface IManga {
id: number
sourceId: string
url: string
title: string
thumbnailUrl: string
artist: string
author: string
description: string
genre: string
status: string
inLibrary: boolean
source: ISource
}
interface IChapter {
@@ -36,12 +53,25 @@ interface IChapter {
chapter_number: number
scanlator: String
mangaId: number
chapterIndex: number
chapterCount: number
pageCount: number
}
interface IPartialChpter {
pageCount: number
chapterIndex: number
chapterCount: number
}
interface ICategory {
id: number
order: number
name: String
isLanding: boolean
}
interface INavbarOverride {
status: boolean
value: any
}
+6 -5
View File
@@ -2,19 +2,20 @@
* 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 { useState, Dispatch, SetStateAction } from 'react';
import React, { useState, Dispatch, SetStateAction } from 'react';
import storage from './localStorage';
// eslint-disable-next-line max-len
export default function useLocalStorage<T>(key: string, defaultValue: T) : [T, Dispatch<SetStateAction<T>>] {
const [storedValue, setStoredValue] = useState<T>(storage.getItem(key, defaultValue));
export default function useLocalStorage<T>(key: string, defaultValue: T | (() => T)) : [T, Dispatch<SetStateAction<T>>] {
const initialState = defaultValue instanceof Function ? defaultValue() : defaultValue;
const [storedValue, setStoredValue] = useState<T>(storage.getItem(key, initialState));
const setValue = (value: T | ((prevState: T) => T)) => {
const setValue = ((value: T | ((prevState: T) => T)) => {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
storage.setItem(key, valueToStore);
};
}) as React.Dispatch<React.SetStateAction<T>>;
return [storedValue, setValue];
}
+12
View File
@@ -1846,6 +1846,13 @@
dependencies:
"@types/react" "*"
"@types/react-lazyload@^3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@types/react-lazyload/-/react-lazyload-3.1.0.tgz#97b167266afbc75f432eca01c50555adae21c5a4"
integrity sha512-JnVJb+6cUrIk4Fo/zc/4NuFtm0h3XeNlN4Gt++WEHGeUDtlhnF1lXRz0WoqNmh5gH3oyeYOJXIZ8MoPL9ehp0g==
dependencies:
"@types/react" "*"
"@types/react-router-dom@^5.1.6":
version "5.1.6"
resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.6.tgz#07b14e7ab1893a837c8565634960dc398564b1fb"
@@ -9353,6 +9360,11 @@ react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339"
integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==
react-lazyload@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/react-lazyload/-/react-lazyload-3.2.0.tgz#497bd06a6dbd7015e3376e1137a67dc47d2dd021"
integrity sha512-zJlrG8QyVZz4+xkYZH5v1w3YaP5wEFaYSUWC4CT9UXfK75IfRAIEdnyIUF+dXr3kX2MOtL1lUaZmaQZqrETwgw==
react-redux@^7.1.1:
version "7.2.2"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.2.tgz#03862e803a30b6b9ef8582dadcc810947f74b736"