Localize WebView and Login pages (#1522)

* Localize WebView and Login pages

* Switch to JTE for page rendering

* Lint

* Add gradle task dependency

* JTE -> KTE

* ShouldRunAfter

* I guess we must

---------

Co-authored-by: Syer10 <syer10@users.noreply.github.com>
This commit is contained in:
Constantin Piber
2025-07-15 21:38:20 +02:00
committed by GitHub
parent 3bac176bf6
commit d050bfdc68
8 changed files with 115 additions and 42 deletions

View File

@@ -11,6 +11,7 @@ plugins {
alias(libs.plugins.download)
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.moko) apply false
alias(libs.plugins.jte) apply false
}
allprojects {

View File

@@ -4,6 +4,7 @@ coroutines = "1.10.2"
serialization = "1.9.0"
okhttp = "5.1.0" # Major version is locked by Tachiyomi extensions
javalin = "6.7.0"
jte = "3.2.1"
jackson = "2.18.3" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
exposed = "0.61.0"
dex2jar = "v64" # Stuck until https://github.com/ThexXTURBOXx/dex2jar/issues/27 is fixed
@@ -49,9 +50,12 @@ okio = "com.squareup.okio:okio:3.15.0"
# Javalin api
javalin-core = { module = "io.javalin:javalin", version.ref = "javalin" }
javalin-openapi = { module = "io.javalin:javalin-openapi", version.ref = "javalin" }
javalin-rendering = { module = "io.javalin:javalin-rendering", version.ref = "javalin" }
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
jackson-annotations = { module = "com.fasterxml.jackson.core:jackson-annotations", version.ref = "jackson" }
jte = { module = "gg.jte:jte", version.ref = "jte" }
kte = { module = "gg.jte:jte-kotlin", version.ref = "jte" }
# GraphQL
graphql-kotlin-server = { module = "com.expediagroup:graphql-kotlin-server", version.ref = "graphqlkotlin" }
@@ -177,6 +181,9 @@ shadowjar = { id = "com.github.johnrengelman.shadow", version = "8.1.1"}
# Moko
moko = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko" }
# JTE
jte = { id = "gg.jte.gradle", version.ref = "jte" }
[bundles]
shared = [
"kotlin-stdlib-jdk8",
@@ -216,6 +223,8 @@ okhttp = [
javalin = [
"javalin-core",
#"javalin-openapi",
"javalin-rendering",
"jte",
]
jackson = [
"jackson-databind",

View File

@@ -25,6 +25,11 @@ plugins {
.get()
.pluginId,
)
id(
libs.plugins.jte
.get()
.pluginId,
)
}
dependencies {
@@ -96,6 +101,12 @@ dependencies {
implementation(libs.cron4j)
implementation(libs.cronUtils)
compileOnly(libs.kte)
}
jte {
generate()
}
application {
@@ -212,4 +223,8 @@ tasks {
)
}
}
runKtlintCheckOverMainSourceSet {
mustRunAfter(generateJte)
}
}

View File

@@ -80,4 +80,23 @@
<string name="manga_status_publishing_finished">Publishing Finished</string>
<string name="manga_status_cancelled">Cancelled</string>
<string name="manga_status_on_hiatus">On Hiatus</string>
</resources>
<string name="label_error">Error</string>
<string name="label_version">Version <xliff:g id="version" example="v2.0.1833">%1$s</xliff:g></string>
<string name="webview_label_title">Suwayomi WebView</string>
<string name="webview_label_disconnected">Disconnected, please refresh</string>
<string name="webview_label_reversescroll">Reverse Scrolling</string>
<string name="webview_label_bindingshint">Note: While focus is on the WebView part, no keybinds, including refresh, will be handled by the browser.</string>
<string name="webview_label_init">Initializing... Please wait</string>
<string name="webview_label_getstarted">Enter a URL to get started</string>
<string name="webview_label_loading">Loading page...</string>
<string name="webview_placeholder_url">Enter URL...</string>
<string name="login_label_title">Suwayomi Login</string>
<string name="login_label_username">Username</string>
<string name="login_label_password">Password</string>
<string name="login_label_login">Log In</string>
<string name="login_placeholder_username">Type username...</string>
<string name="login_placeholder_password">Secret...</string>
</resources>

View File

@@ -1,8 +1,14 @@
@import suwayomi.tachidesk.i18n.MR
@import suwayomi.tachidesk.server.generated.BuildConfig
@param locale: java.util.Locale
@param error: String
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Suwayomi Login</title>
<title>${MR.strings.login_label_title.localized(locale)}</title>
<style>
* {
box-sizing: border-box;
@@ -143,22 +149,22 @@
<h1>Suwayomi</h1>
</header>
<main>
<div class="error">[ERROR]</div>
<div class="error">${error}</div>
<form method="POST">
<h2>Login</h2>
<div class="controls">
<label for="user">Username:</label>
<input type="text" name="user" id="user" required placeholder="Type username..."/>
<label for="pass">Password:</label>
<input type="password" name="pass" id="pass" required placeholder="Secret..."/>
<label for="user">${MR.strings.login_label_username.localized(locale)}:</label>
<input type="text" name="user" id="user" required placeholder="${MR.strings.login_placeholder_username.localized(locale)}"/>
<label for="pass">${MR.strings.login_label_password.localized(locale)}:</label>
<input type="password" name="pass" id="pass" required placeholder="${MR.strings.login_placeholder_password.localized(locale)}"/>
</div>
<div class="submit">
<button type="submit">Log In</button>
<button type="submit">${MR.strings.login_label_login.localized(locale)}</button>
</div>
</form>
</main>
<footer>
<p>Suwayomi: Version [VERSION]</p>
<p>Suwayomi: ${MR.strings.label_version.localized(locale, BuildConfig.VERSION)}</p>
</footer>
</body>
</html>

View File

@@ -1,8 +1,12 @@
@import suwayomi.tachidesk.i18n.MR
@param locale: java.util.Locale
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" />
<title>Suwayomi Webview</title>
<title>${MR.strings.webview_label_title.localized(locale)}</title>
<style>
* {
box-sizing: border-box;
@@ -20,7 +24,7 @@
letter-spacing: 0em;
}
body.disconnected::after {
content: 'Disconnected, please refresh';
content: "${MR.strings.webview_label_disconnected.localized(locale)}";
position: absolute;
inset: 0;
background: rgba(150, 0, 0, 0.5);
@@ -141,18 +145,18 @@
</head>
<body>
<header>
<h1 id="title">Suwayomi: WebView</h1>
<h1 id="title">${MR.strings.webview_label_title.localized(locale)}</h1>
<nav>
<form id="browseForm">
<input type="text" id="url" name="url" placeholder="Enter URL..." disabled/>
<input type="text" id="url" name="url" placeholder="${MR.strings.webview_placeholder_url.localized(locale)}" disabled/>
<button type="submit" id="goButton" disabled><span class="arrow-right"></span></button>
</form>
<label><input type="checkbox" id="reverseScroll" disabled/> Reverse Scrolling</label>
<label><input type="checkbox" id="reverseScroll" disabled/> ${MR.strings.webview_label_reversescroll.localized(locale)}</label>
</nav>
<p><i>Note: While focus is on the WebView part, no keybinds, including refresh, will be handled by the browser</i></p>
<p><i>${MR.strings.webview_label_bindingshint.localized(locale)}</i></p>
</header>
<main>
<div class="message" id="message">Initializing... Please wait</div>
<div class="message" id="message">${MR.strings.webview_label_init.localized(locale)}</div>
<div class="status" id="status"></div>
<canvas id="frame"></canvas>
<input type="text" id="inputtrap" autocomplete="off"/>
@@ -168,6 +172,7 @@
const urlInput = document.getElementById('url');
const titleDiv = document.getElementById('title');
const reverseToggle = document.getElementById('reverseScroll');
const origTitle = document.title;
try {
const socketUrl = (window.location.origin + window.location.pathname).replace(/^http/,'ws');
@@ -200,8 +205,8 @@
const setTitle = (title) => {
if (!title) {
document.title = "Suwayomi Webview";
titleDiv.textContent = "Suwayomi Webview";
document.title = origTitle;
titleDiv.textContent = origTitle;
} else {
document.title = "Suwayomi: " + title;
titleDiv.textContent = "Suwayomi: " + title;
@@ -213,11 +218,11 @@
urlInput.value = u;
setHash(u);
setTitle();
messageDiv.textContent = 'Enter a URL to get started';
messageDiv.textContent = "${MR.strings.webview_label_getstarted.localized(locale)}";
ctx.clearRect(0, 0, frame.width, frame.height);
return;
}
messageDiv.textContent = "Loading page...";
messageDiv.textContent = "${MR.strings.webview_label_loading.localized(locale)}";
messageDiv.classList.remove('error');
urlInput.value = u;
socket.send(JSON.stringify({ type: 'loadUrl', url: u, width: frame.clientWidth, height: frame.clientHeight }));
@@ -265,7 +270,7 @@
break;
case "load": {
if (obj.error) {
messageDiv.textContent = "Error: " + obj.error;
messageDiv.textContent = "${MR.strings.label_error.localized(locale)}: " + obj.error;
messageDiv.classList.add('error');
} else {
messageDiv.textContent = "";
@@ -286,7 +291,7 @@
} break;
case "consoleMessage": {
const lg = obj.severity == 4 ? console.error : obj.severity == 3 ? console.warn : console.log;
lg(`${obj.source}:${obj.line}:`, obj.message);
lg(obj.source + ':' + obj.line + ':', obj.message);
} break;
default:
console.warn("Unknown event", obj.type)
@@ -296,7 +301,7 @@
socket.addEventListener('close', e => {
if (e.wasClean) {
console.log(`WebSocket connection closed cleanly, code=${e.code}, reason=${e.reason}`);
console.log(`WebSocket connection closed cleanly, code=` + e.code + `, reason=` + e.reason);
} else {
console.error('WebSocket connection died');
}
@@ -304,7 +309,7 @@
});
socket.addEventListener('error', e => {
messageDiv.textContent = "Error: " + (e.message || e.reason || e);
messageDiv.textContent = "${MR.strings.label_error.localized(locale)}: " + (e.message || e.reason || e);
messageDiv.classList.add('error');
console.error('WebSocket error:', e);
});
@@ -417,7 +422,7 @@
attachEvents();
frameInput.focus();
} catch (e) {
messageDiv.textContent = "Error: " + (e.message || e);
messageDiv.textContent = "${MR.strings.label_error.localized(locale)}: " + (e.message || e);
messageDiv.classList.add('error');
console.error(e);
}

View File

@@ -11,21 +11,31 @@ import io.javalin.http.ContentType
import io.javalin.http.HttpStatus
import io.javalin.websocket.WsConfig
import suwayomi.tachidesk.global.impl.WebView
import suwayomi.tachidesk.i18n.LocalizationHelper
import suwayomi.tachidesk.server.util.handler
import suwayomi.tachidesk.server.util.queryParam
import suwayomi.tachidesk.server.util.withOperation
import java.util.Locale
object WebViewController {
val webview =
handler(
queryParam<String?>("lang"),
documentWith = {
withOperation {
summary("WebView")
description("Opens and browses WebView")
}
},
behaviorOf = { ctx ->
behaviorOf = { ctx, lang ->
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.contentType(ContentType.TEXT_HTML)
ctx.result(javaClass.getResourceAsStream("/webview.html")!!)
ctx.render(
"Webview.jte",
mapOf(
"locale" to locale,
),
)
},
withResults = { mime<String>(HttpStatus.OK, "text/html") },
)

View File

@@ -7,6 +7,8 @@ package suwayomi.tachidesk.server
* 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 gg.jte.ContentType
import gg.jte.TemplateEngine
import io.github.oshai.kotlinlogging.KotlinLogging
import io.javalin.Javalin
import io.javalin.apibuilder.ApiBuilder.path
@@ -15,6 +17,7 @@ import io.javalin.http.HttpStatus
import io.javalin.http.RedirectResponse
import io.javalin.http.UnauthorizedResponse
import io.javalin.http.staticfiles.Location
import io.javalin.rendering.template.JavalinJte
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -25,14 +28,15 @@ import org.eclipse.jetty.server.ServerConnector
import suwayomi.tachidesk.global.GlobalAPI
import suwayomi.tachidesk.graphql.GraphQL
import suwayomi.tachidesk.graphql.types.AuthMode
import suwayomi.tachidesk.i18n.LocalizationHelper
import suwayomi.tachidesk.manga.MangaAPI
import suwayomi.tachidesk.opds.OpdsAPI
import suwayomi.tachidesk.server.generated.BuildConfig
import suwayomi.tachidesk.server.util.Browser
import suwayomi.tachidesk.server.util.WebInterfaceManager
import uy.kohesive.injekt.injectLazy
import java.io.IOException
import java.net.URLEncoder
import java.util.Locale
import java.util.concurrent.CompletableFuture
import kotlin.concurrent.thread
import kotlin.time.Duration.Companion.days
@@ -49,6 +53,8 @@ object JavalinSetup {
fun javalinSetup() {
val app =
Javalin.create { config ->
val templateEngine = TemplateEngine.createPrecompiled(ContentType.Html)
config.fileRenderer(JavalinJte(templateEngine))
if (serverConfig.webUIEnabled.value) {
val serveWebUI = {
config.spaRoot.addFile("/", applicationDirs.webUIRoot + "/index.html", Location.EXTERNAL)
@@ -118,16 +124,17 @@ object JavalinSetup {
}
app.get("/login.html") { ctx ->
var page =
this::class.java
.getResourceAsStream("/static/login.html")!!
.use { it.readAllBytes() }
.toString(Charsets.UTF_8)
page = page.replace("[VERSION]", BuildConfig.VERSION).replace("[ERROR]", "")
val locale: Locale = LocalizationHelper.ctxToLocale(ctx)
ctx.header("content-type", "text/html")
val httpCacheSeconds = 1.days.inWholeSeconds
ctx.header("cache-control", "max-age=$httpCacheSeconds")
ctx.result(page)
ctx.render(
"Login.jte",
mapOf(
"locale" to locale,
"error" to "",
),
)
}
app.post("/login.html") { ctx ->
@@ -147,15 +154,16 @@ object JavalinSetup {
throw RedirectResponse(HttpStatus.SEE_OTHER)
}
var page =
this::class.java
.getResourceAsStream("/static/login.html")!!
.use { it.readAllBytes() }
.toString(Charsets.UTF_8)
page = page.replace("[VERSION]", BuildConfig.VERSION).replace("[ERROR]", "Invalid username or password")
val locale: Locale = LocalizationHelper.ctxToLocale(ctx)
ctx.header("content-type", "text/html")
ctx.req().session.invalidate()
ctx.result(page)
ctx.render(
"Login.jte",
mapOf(
"locale" to locale,
"error" to "Invalid username or password",
),
)
}
app.beforeMatched { ctx ->