Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d9f5b0bd9 | |||
| 5cbb2a0481 | |||
| a3b17365b7 | |||
| 2847554b8f | |||
| fb166eadd4 | |||
| 046c11f785 | |||
| eac7436b18 | |||
| 1a23804e51 | |||
| 08be142858 | |||
| f421dbfe69 | |||
| 0d7ad65dbe | |||
| 9e946406bc | |||
| 9142d46fae | |||
| 39ed134f96 | |||
| 7e4b495398 | |||
| c12242b760 | |||
| 8b2fd28a54 | |||
| 466f21a7e8 | |||
| 26a7e2f1cd | |||
| c155d78d52 | |||
| 687dad5fc0 | |||
| 33c4cdbc48 | |||
| e46cb9738f | |||
| aef37fcc9e | |||
| ac51f40a91 | |||
| cd82af2d76 | |||
| 790040cd68 | |||
| 95465ec265 | |||
| 5313d91bf2 | |||
| 63783984c6 | |||
| 97b9b1b6c9 | |||
| 34a7c24e0b | |||
| 12765a771f | |||
| 39850c71b0 | |||
| 9e43645a67 | |||
| 7dc7f4d905 | |||
| c537c1bf29 | |||
| 5f3ddbd1b2 | |||
| f297e3790c | |||
| ef3532357f | |||
| 48f29edf6c |
@@ -0,0 +1,7 @@
|
||||
org.gradle.daemon=false
|
||||
org.gradle.jvmargs=-Xmx5120m
|
||||
org.gradle.workers.max=5
|
||||
org.gradle.parallel=true
|
||||
|
||||
kotlin.incremental=false
|
||||
kotlin.compiler.execution.strategy=in-process
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
cp ../master/repo/* .
|
||||
new_build=$(ls | tail -1)
|
||||
echo "New build file name: $new_build"
|
||||
|
||||
cp -f $new_build Tachidesk-latest.jar
|
||||
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git status
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
git add .
|
||||
git commit -m "Update repo"
|
||||
git push
|
||||
else
|
||||
echo "No changes to commit"
|
||||
fi
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
|
||||
mkdir -p repo/
|
||||
|
||||
# Get last commit message
|
||||
last_commit_log=$(git log -1 --pretty=format:"%s")
|
||||
echo "last commit log: $last_commit_log"
|
||||
|
||||
filter_count=$(echo "$last_commit_log" | grep -c "[RELEASE CI]" )
|
||||
|
||||
if [ "$filter_count" -gt 0 ]; then
|
||||
cp server/build/Tachidesk-*.jar repo/
|
||||
fi
|
||||
@@ -0,0 +1,84 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check_wrapper:
|
||||
name: Validate Gradle Wrapper
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@v1
|
||||
|
||||
build:
|
||||
name: Build FatJar
|
||||
needs: check_wrapper
|
||||
if: "!startsWith(github.event.head_commit.message, '[SKIP CI]')"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Cancel previous runs
|
||||
uses: styfle/cancel-workflow-action@0.5.0
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
|
||||
- name: Checkout master branch
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
ref: master
|
||||
path: master
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up JDK 1.8
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 1.8
|
||||
|
||||
- name: Copy CI gradle.properties
|
||||
run: |
|
||||
cd master
|
||||
mkdir -p ~/.gradle
|
||||
cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties
|
||||
|
||||
- name: Download and process android.jar
|
||||
if: github.event_name == 'push' && github.repository == 'AriaMoradi/Tachidesk'
|
||||
run: |
|
||||
cd master
|
||||
./scripts/getAndroid.sh
|
||||
|
||||
- name: Build the Jar
|
||||
uses: eskatos/gradle-command-action@v1
|
||||
with:
|
||||
build-root-directory: master
|
||||
wrapper-directory: master
|
||||
arguments: :server:shadowJar --stacktrace
|
||||
wrapper-cache-enabled: true
|
||||
dependencies-cache-enabled: true
|
||||
configuration-cache-enabled: true
|
||||
|
||||
- name: Create repo artifacts
|
||||
if: github.event_name == 'push' && github.repository == 'AriaMoradi/Tachidesk'
|
||||
run: |
|
||||
cd master
|
||||
./.github/scripts/create-repo.sh
|
||||
|
||||
- name: Checkout repo branch
|
||||
if: github.event_name == 'push' && github.repository == 'AriaMoradi/Tachidesk'
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
ref: repo
|
||||
path: repo
|
||||
|
||||
- name: Deploy repo
|
||||
if: github.event_name == 'push' && github.repository == 'AriaMoradi/Tachidesk'
|
||||
run: |
|
||||
cd repo
|
||||
../master/.github/scripts/commit-repo.sh
|
||||
@@ -1,33 +1,50 @@
|
||||
# Tachidesk
|
||||
A not so much port of [Tachiyomi](https://tachiyomi.org/) to the web (and later Electron for the desktop experience)!
|
||||
A free and open source manga reader than runs extensions built for [Tachiyomi](https://tachiyomi.org/) which runs on desktop operating systems.
|
||||
|
||||
Ability to read and write Tachiyomi compatible backups and syncing is a planned feature.
|
||||
|
||||
## How does it work?
|
||||
This project has two components:
|
||||
1. **server:** contains some of the original Tachiyomi code and serves a REST API
|
||||
2. **webUI:** A react project that works with the server to do the presentation
|
||||
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.
|
||||
|
||||
## How do I run the thing?
|
||||
### Get Android stubs jar(do this only once)
|
||||
#### Running pre-built jar packages
|
||||
Download the latest (or a working more stable) release from [the repo branch](https://github.com/AriaMoradi/Tachidesk/tree/repo) or obtain it from [the releases section](https://github.com/AriaMoradi/Tachidesk/releases).
|
||||
|
||||
Double click on the jar file or run `java -jar Tachidesk-latest.jar` or `java -jar Tachidesk-vX.Y.Z-rxxx.jar`
|
||||
|
||||
The server will be running on `http://localhost:4567` open this url in your browser.
|
||||
|
||||
## Building from source
|
||||
### Get Android stubs jar
|
||||
#### Manual download
|
||||
Download [android.jar](https://raw.githubusercontent.com/AriaMoradi/Tachidesk/android-jar/android.jar) and put it under `AndroidCompat/lib`.
|
||||
#### Building from source(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 `scripts/getAndroid.sh` from project's root directory to download and rebuild the jar file from Google's repository.
|
||||
### building the jar
|
||||
run `./gradlew :server:fatJar` the resulting jar file will be `server/build/server-1.0-all.jar`. Simply double click on it or run `java -jar server-1.0-all.jar`. The server will be running on `http://localhost:4567` open this url in your browser.
|
||||
## running for development purposes
|
||||
### The Server
|
||||
run `./gradlew :server:run -x :webUI:yarn_build --stacktrace` to run the server
|
||||
### the webUI
|
||||
how to do it is described in `webUI/react/README.md` but for short,
|
||||
Run `./gradlew shadowJar` the resulting built jar file will be `server/build/Tachidesk-vX.Y.Z-rxxx.jar`.
|
||||
## Running for development purposes
|
||||
### `server` module
|
||||
Run `./gradlew :server:run -x :webUI:copyBuild --stacktrace` to run the server
|
||||
### `webUI` module
|
||||
How to do it is described in `webUI/react/README.md` but for short,
|
||||
first cd into `webUI/react` then run `yarn` to install the node modules(do this only once)
|
||||
then `yarn start` to start the client if a new browser window doesn't start automatically,
|
||||
then open `http://127.0.0.1:3000` in a modern browser.
|
||||
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.
|
||||
|
||||
## Is the application usable? Should I test it?
|
||||
## Is this application usable? Should I test it?
|
||||
Checkout [the state of project](https://github.com/AriaMoradi/Tachidesk/issues/2) to see what's implemented.
|
||||
|
||||
## 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`.
|
||||
|
||||
Parts of [tachiyomi](https://github.com/tachiyomiorg/tachiyomi) is adopted into this codebase, also licensed under `Apache License Version 2.0`.
|
||||
|
||||
## License
|
||||
|
||||
Copyright (C) 2020 Aria Moradi
|
||||
Copyright (C) 2020-2021 Aria Moradi and contributors
|
||||
|
||||
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
|
||||
|
||||
@@ -58,9 +58,8 @@ function dedup() {
|
||||
|
||||
pushd ..
|
||||
dedup AndroidCompat/src/main/java
|
||||
dedup TachiServer/src/main/java
|
||||
dedup Tachiyomi-App/src/main/java
|
||||
dedup Tachiyomi-App/src/compat/java
|
||||
dedup server/src/main/java
|
||||
dedup server/src/main/kotlin
|
||||
popd
|
||||
|
||||
popd
|
||||
|
||||
+19
-1
@@ -1,4 +1,5 @@
|
||||
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||
import java.io.BufferedReader
|
||||
|
||||
plugins {
|
||||
// id("org.jetbrains.kotlin.jvm") version "1.4.21"
|
||||
@@ -6,6 +7,8 @@ plugins {
|
||||
id("com.github.johnrengelman.shadow") version "6.1.0"
|
||||
}
|
||||
|
||||
val TachideskVersion = "v0.0.2"
|
||||
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
@@ -102,6 +105,19 @@ sourceSets {
|
||||
}
|
||||
}
|
||||
|
||||
val TachideskRevision = Runtime
|
||||
.getRuntime()
|
||||
.exec("git rev-list master --count")
|
||||
.let { process ->
|
||||
process.waitFor()
|
||||
val output = process.inputStream.use {
|
||||
it.bufferedReader().use(BufferedReader::readText)
|
||||
}
|
||||
process.destroy()
|
||||
"r"+output.trim()
|
||||
|
||||
}
|
||||
|
||||
tasks {
|
||||
jar {
|
||||
manifest {
|
||||
@@ -115,12 +131,14 @@ tasks {
|
||||
}
|
||||
shadowJar {
|
||||
manifest.inheritFrom(jar.get().manifest) //will make your shadowJar (produced by jar task) runnable
|
||||
archiveBaseName.set("Tachidesk")
|
||||
archiveVersion.set(TachideskVersion)
|
||||
archiveClassifier.set(TachideskRevision)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<ShadowJar> {
|
||||
destinationDir = File("$rootDir/server/build")
|
||||
//dependsOn(":webUI:copyBuild")
|
||||
}
|
||||
|
||||
tasks.named("processResources") {
|
||||
|
||||
@@ -36,10 +36,14 @@ class Main {
|
||||
androidCompat.startApp(App())
|
||||
|
||||
|
||||
|
||||
val app = Javalin.create { config ->
|
||||
// config.addSinglePageRoot("/", "")
|
||||
config.addStaticFiles("/react")
|
||||
try {
|
||||
this::class.java.classLoader.getResource("/react/index.html")
|
||||
config.addStaticFiles("/react")
|
||||
config.addSinglePageRoot("/","/react/index.html")
|
||||
} catch (e: RuntimeException) {
|
||||
println("Warning: react build files are missing.")
|
||||
}
|
||||
}.start(4567)
|
||||
|
||||
|
||||
@@ -68,12 +72,12 @@ class Main {
|
||||
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))
|
||||
ctx.json(getMangaList(sourceId, pageNum, popular = true))
|
||||
}
|
||||
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))
|
||||
ctx.json(getMangaList(sourceId, pageNum, popular = false))
|
||||
}
|
||||
|
||||
app.get("/api/v1/manga/:mangaId/") { ctx ->
|
||||
@@ -89,11 +93,32 @@ class Main {
|
||||
app.get("/api/v1/manga/:mangaId/chapter/:chapterId") { ctx ->
|
||||
val chapterId = ctx.pathParam("chapterId").toInt()
|
||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
||||
ctx.json(getPages(chapterId,mangaId))
|
||||
ctx.json(getPages(chapterId, mangaId))
|
||||
}
|
||||
|
||||
// global search
|
||||
app.get("/api/v1/search/:searchTerm") { ctx ->
|
||||
val searchTerm = ctx.pathParam("searchTerm")
|
||||
ctx.json(sourceGlobalSearch(searchTerm))
|
||||
}
|
||||
|
||||
// single source search
|
||||
app.get("/api/v1/source/:sourceId/search/:searchTerm") { ctx ->
|
||||
val sourceId = ctx.pathParam("sourceId").toLong()
|
||||
val searchTerm = ctx.pathParam("searchTerm")
|
||||
ctx.json(sourceSearch(sourceId, searchTerm))
|
||||
}
|
||||
|
||||
// source filter list
|
||||
app.get("/api/v1/source/:sourceId/filters/") { ctx ->
|
||||
val sourceId = ctx.pathParam("sourceId").toLong()
|
||||
ctx.json(sourceFilters(sourceId))
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package ir.armor.tachidesk.util
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
|
||||
fun sourceFilters(sourceId: Long) {
|
||||
val source = getHttpSource(sourceId)
|
||||
//source.getFilterList().toItems()
|
||||
}
|
||||
|
||||
fun sourceSearch(sourceId: Long, searchTerm: String) {
|
||||
val source = getHttpSource(sourceId)
|
||||
//source.fetchSearchManga()
|
||||
}
|
||||
|
||||
fun sourceGlobalSearch(searchTerm: String) {
|
||||
|
||||
}
|
||||
|
||||
data class FilterWrapper(
|
||||
val type: String,
|
||||
val filter: Any
|
||||
)
|
||||
|
||||
//private fun FilterList.toItems(): List<FilterWrapper> {
|
||||
// return mapNotNull { filter ->
|
||||
// when (filter) {
|
||||
// is Filter.Header -> FilterWrapper("Header",filter)
|
||||
// is Filter.Separator -> FilterWrapper("Separator",filter)
|
||||
// is Filter.CheckBox -> FilterWrapper("CheckBox",filter)
|
||||
// is Filter.TriState -> FilterWrapper("TriState",filter)
|
||||
// is Filter.Text -> FilterWrapper("Text",filter)
|
||||
// is Filter.Select<*> -> FilterWrapper("Select",filter)
|
||||
// is Filter.Group<*> -> {
|
||||
// val group = GroupItem(filter)
|
||||
// val subItems = filter.state.mapNotNull {
|
||||
// when (it) {
|
||||
// is Filter.CheckBox -> FilterWrapper("CheckBox",filter)
|
||||
// is Filter.TriState -> FilterWrapper("TriState",filter)
|
||||
// is Filter.Text -> FilterWrapper("Text",filter)
|
||||
// is Filter.Select<*> -> FilterWrapper("Select",filter)
|
||||
// else -> null
|
||||
// } as? ISectionable<*, *>
|
||||
// }
|
||||
// subItems.forEach { it.header = group }
|
||||
// group.subItems = subItems
|
||||
// group
|
||||
// }
|
||||
// is Filter.Sort -> {
|
||||
// val group = SortGroup(filter)
|
||||
// val subItems = filter.values.map {
|
||||
// SortItem(it, group)
|
||||
// }
|
||||
// group.subItems = subItems
|
||||
// group
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -4,11 +4,11 @@ plugins {
|
||||
|
||||
node {
|
||||
workDir = file("${project.projectDir}/react/")
|
||||
nodeModulesDir = file("${project.projectDir}/react/node_modules")
|
||||
nodeModulesDir = file("${project.projectDir}/react/")
|
||||
}
|
||||
|
||||
tasks.named("yarn_build") {
|
||||
dependsOn("yarn_install")
|
||||
dependsOn("yarn") // install node_moduels
|
||||
}
|
||||
|
||||
tasks.register<Copy>("copyBuild") {
|
||||
|
||||
@@ -47,4 +47,4 @@
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"typescript": "^4.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import Extensions from './screens/Extensions';
|
||||
import MangaList from './screens/MangaList';
|
||||
import Manga from './screens/Manga';
|
||||
import Reader from './screens/Reader';
|
||||
import Search from './screens/Search';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -16,6 +17,9 @@ export default function App() {
|
||||
<NavBar />
|
||||
|
||||
<Switch>
|
||||
<Route path="/search">
|
||||
<Search />
|
||||
</Route>
|
||||
<Route path="/extensions">
|
||||
<Extensions />
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import MangaCard from './MangaCard';
|
||||
|
||||
interface IProps{
|
||||
mangas: IManga[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
export default function MangaGrid(props: IProps) {
|
||||
const { mangas, message } = props;
|
||||
let mapped;
|
||||
|
||||
if (mangas.length === 0) {
|
||||
mapped = <h3>{message !== undefined ? message : 'loading...'}</h3>;
|
||||
} else {
|
||||
mapped = (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, auto)', gridGap: '1em' }}>
|
||||
{mangas.map((it) => (
|
||||
<MangaCard manga={it} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
@@ -48,6 +48,14 @@ export default function TemporaryDrawer({ drawerOpen, setDrawerOpen }: IProps) {
|
||||
<ListItemText primary="Sources" />
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import MangaCard from '../components/MangaCard';
|
||||
import MangaGrid from '../components/MangaGrid';
|
||||
|
||||
export default function MangaList(props: { popular: boolean }) {
|
||||
const { sourceId } = useParams<{sourceId: string}>();
|
||||
let mapped;
|
||||
const [mangas, setMangas] = useState<IManga[]>([]);
|
||||
const [lastPageNum] = useState<number>(1);
|
||||
|
||||
@@ -17,17 +16,5 @@ export default function MangaList(props: { popular: boolean }) {
|
||||
));
|
||||
}, []);
|
||||
|
||||
if (mangas.length === 0) {
|
||||
mapped = <h3>wait</h3>;
|
||||
} else {
|
||||
mapped = (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, auto)', gridGap: '1em' }}>
|
||||
{mangas.map((it) => (
|
||||
<MangaCard manga={it} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return mapped;
|
||||
return <MangaGrid mangas={mangas} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { useState } from 'react';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import MangaGrid from '../components/MangaGrid';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
TextField: {
|
||||
margin: theme.spacing(1),
|
||||
width: '25ch',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export default function Search() {
|
||||
const classes = useStyles();
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
const [mangas, setMangas] = useState<IManga[]>([]);
|
||||
const [message, setMessage] = useState<string>('');
|
||||
|
||||
const textInput = React.createRef<HTMLInputElement>();
|
||||
|
||||
function doSearch() {
|
||||
if (textInput.current) {
|
||||
const { value } = textInput.current;
|
||||
if (value === '') { setError(true); } else {
|
||||
setError(false);
|
||||
setMangas([]);
|
||||
setMessage('button pressed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mangaGrid = <MangaGrid mangas={mangas} message={message} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className={classes.root} noValidate autoComplete="off">
|
||||
<TextField inputRef={textInput} error={error} id="standard-basic" label="Search text.." />
|
||||
<Button variant="contained" color="primary" onClick={() => doSearch()}>
|
||||
Primary
|
||||
</Button>
|
||||
</form>
|
||||
{mangaGrid}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user