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
+170
View File
@@ -0,0 +1,170 @@
@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>${MR.strings.login_label_title.localized(locale)}</title>
<style>
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
}
body {
display: flex;
flex-direction: column;
background-color: rgb(12, 16, 33);
font-family: "Roboto","Helvetica","Arial",sans-serif;
font-weight: 400;
letter-spacing: 0em;
}
button[disabled], input[disabled] {
cursor: not-allowed;
}
header {
background-color: rgb(34, 38, 53);
box-shadow: rgba(0, 0, 0, 0.2) 0px 2px 4px -1px, rgba(0, 0, 0, 0.14) 0px 4px 5px 0px, rgba(0, 0, 0, 0.12) 0px 1px 10px 0px;
color: #fff;
padding: 8px 32px;
}
header h1, header p {
margin: 0;
}
footer {
color: #fff;
padding: 8px;
}
footer p {
margin: 0;
font-size: 0.7rem;
}
main {
height: 100%;
}
main {
position: relative;
padding-top: 24px;
}
form {
margin: 8px;
padding: 8px 24px;
border-radius: 8px;
border: 1px solid rgb(12, 16, 33);
background-color: rgb(6, 8, 16);
color: white;
}
.error {
margin: 8px;
padding: 8px 16px;
border-radius: 8px;
border: 1px solid #b71c1c;
background-color: #c62828;
color: white;
}
.error:empty {
display: none;
}
form label {
cursor: pointer;
}
form button {
all: unset;
padding: 8px;
line-height: 1.75;
text-align: center;
min-width: 64px;
border-radius: 4px;
padding: 6px 8px;
color: rgb(91, 116, 239);
text-transform: uppercase;
letter-spacing: 0.02857em;
}
form button:not([disabled]) {
cursor: pointer;
}
form button:not([disabled]):hover {
background-color: rgba(91, 116, 239, 0.08);
}
form input {
all: unset;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.23);
padding: 6px 12px;
width: auto;
min-width: 0;
}
form input:hover {
border-color: white;
}
form input:focus {
border-color: rgb(91, 116, 239);
}
form .controls {
display: grid;
align-items: center;
grid-template-columns: 1fr;
}
form .controls > :nth-child(even):not(:last-child) {
margin-bottom: 6px;
}
form .submit {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 24px;
}
@media (min-width: 500px) {
form {
width: 100%;
max-width: 450px;
margin: 8px auto;
}
.error {
width: 100%;
max-width: 450px;
margin: 8px auto;
}
form .controls {
grid-template-columns: auto 1fr;
column-gap: 16px;
row-gap: 6px;
}
form .controls > :nth-child(even):not(:last-child) {
margin-bottom: 0px;
}
}
</style>
</head>
<body>
<header>
<h1>Suwayomi</h1>
</header>
<main>
<div class="error">${error}</div>
<form method="POST">
<h2>Login</h2>
<div class="controls">
<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">${MR.strings.login_label_login.localized(locale)}</button>
</div>
</form>
</main>
<footer>
<p>Suwayomi: ${MR.strings.label_version.localized(locale, BuildConfig.VERSION)}</p>
</footer>
</body>
</html>
+431
View File
@@ -0,0 +1,431 @@
@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>${MR.strings.webview_label_title.localized(locale)}</title>
<style>
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
}
body {
display: flex;
flex-direction: column;
font-family: "Roboto","Helvetica","Arial",sans-serif;
font-weight: 400;
letter-spacing: 0em;
}
body.disconnected::after {
content: "${MR.strings.webview_label_disconnected.localized(locale)}";
position: absolute;
inset: 0;
background: rgba(150, 0, 0, 0.5);
color: white;
text-align: center;
align-content: center;
font-size: 2rem;
}
button[disabled], input[disabled] {
cursor: not-allowed;
}
header {
background-color: rgb(34, 38, 53);
box-shadow: rgba(0, 0, 0, 0.2) 0px 2px 4px -1px, rgba(0, 0, 0, 0.14) 0px 4px 5px 0px, rgba(0, 0, 0, 0.12) 0px 1px 10px 0px;
color: #fff;
padding: 8px 32px;
}
header h1, header p {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
header nav {
display: flex;
flex-wrap: wrap;
column-gap: 20px;
align-items: center;
}
header form {
display: flex;
gap: 5px;
flex: auto 1 1;
min-width: 400px;
}
header label {
flex: auto 0 0;
cursor: pointer;
}
header button {
all: unset;
padding: 8px;
border-radius: 50%;
min-width: 1em;
line-height: 1;
text-align: center;
}
header button:not([disabled]) {
cursor: pointer;
}
header button:not([disabled]):hover {
background-color: rgba(255, 255, 255, 0.08);
}
header input {
flex: 100% 1 1;
}
main, iframe {
height: 100%;
}
main {
position: relative;
}
canvas, input#inputtrap {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
input#inputtrap {
opacity: 0;
padding: 0;
margin: 0;
border: none;
}
main .message, main .status {
position: relative;
z-index: 1;
}
main .message {
padding: 8px;
max-width: 1100px;
margin: auto;
font-style: italic;
}
main .message.error {
color: red;
font-style: regular;
font-weight: bold;
}
main .message:empty {
display: none;
}
main .status {
position: absolute;
bottom: 0;
left: 0;
max-width: 50%;
background: #555;
color: white;
padding: 2px 4px;
font-size: 0.8rem;
border-top-right-radius: 3px;
}
main .status:empty {
display: none;
}
/* https://css-tricks.com/snippets/css/css-triangle/ */
.arrow-right {
display: inline-block;
width: 0;
height: 0;
border-top: 9px solid transparent;
border-bottom: 9px solid transparent;
border-left: 9px solid currentcolor;
}
</style>
</head>
<body>
<header>
<h1 id="title">${MR.strings.webview_label_title.localized(locale)}</h1>
<nav>
<form id="browseForm">
<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/> ${MR.strings.webview_label_reversescroll.localized(locale)}</label>
</nav>
<p><i>${MR.strings.webview_label_bindingshint.localized(locale)}</i></p>
</header>
<main>
<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"/>
</main>
<script>
const messageDiv = document.getElementById('message');
const statusDiv = document.getElementById('status');
const frame = document.getElementById('frame');
const frameInput = document.getElementById('inputtrap');
const ctx = frame.getContext("2d");
const browseForm = document.getElementById('browseForm');
const goButton = document.getElementById('goButton');
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');
const socket = new WebSocket(socketUrl);
urlInput.disabled = false;
goButton.disabled = false;
reverseToggle.disabled = false;
reverseToggle.checked = window.localStorage.getItem('suwayomi_mouse_reverse_scroll') === "true";
let url = '';
try {
url = window.decodeURIComponent(window.location.hash.replace(/^#/, ''));
} catch (e) {
console.error(e);
}
/// Helpers
const setHash = (u) => {
let current = '';
try {
current = window.decodeURIComponent(window.location.hash.replace(/^#/, ''));
} catch (e) {
console.error(e);
}
if (current != u)
history.pushState(null, null, window.location.origin + window.location.pathname + '#' + window.encodeURIComponent(u));
};
const setTitle = (title) => {
if (!title) {
document.title = origTitle;
titleDiv.textContent = origTitle;
} else {
document.title = "Suwayomi: " + title;
titleDiv.textContent = "Suwayomi: " + title;
}
}
const loadUrl = (u) => {
if (!u) {
urlInput.value = u;
setHash(u);
setTitle();
messageDiv.textContent = "${MR.strings.webview_label_getstarted.localized(locale)}";
ctx.clearRect(0, 0, frame.width, frame.height);
return;
}
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 }));
ctx.clearRect(0, 0, frame.width, frame.height);
};
/// Form
window.addEventListener('hashchange', e => {
const url = window.decodeURIComponent(window.location.hash.replace(/^#/, ''));
loadUrl(url);
console.log('Navigate to', url);
});
browseForm.addEventListener('submit', e => {
e.preventDefault();
const url = urlInput.value;
loadUrl(url);
console.log('Navigate to', url);
});
reverseToggle.addEventListener('change', e => {
window.localStorage.setItem('suwayomi_mouse_reverse_scroll', e.target.checked ? "true" : "false");
});
/// Server events
socket.addEventListener('open', () => {
loadUrl(url);
console.log('WebSocket connection opened');
});
socket.addEventListener('message', e => {
const obj = JSON.parse(e.data);
switch (obj.type) {
case "addressChange":
console.log('Loaded');
messageDiv.textContent = '';
urlInput.value = obj.url;
setHash(obj.url);
setTitle(obj.title);
break;
case "statusChange":
statusDiv.textContent = obj.message;
break;
case "load": {
if (obj.error) {
messageDiv.textContent = "${MR.strings.label_error.localized(locale)}: " + obj.error;
messageDiv.classList.add('error');
} else {
messageDiv.textContent = "";
}
urlInput.value = obj.url;
setTitle(obj.title);
} break;
case "render": {
const img = new Image();
const imgData = new Blob([new Uint8Array(obj.image)], { type: "image/png" });
const url = URL.createObjectURL(imgData);
img.addEventListener('load', e => {
frame.width = img.width;
frame.height = img.height;
ctx.drawImage(img, 0, 0);
});
img.src = url;
} break;
case "consoleMessage": {
const lg = obj.severity == 4 ? console.error : obj.severity == 3 ? console.warn : console.log;
lg(obj.source + ':' + obj.line + ':', obj.message);
} break;
default:
console.warn("Unknown event", obj.type)
break;
}
});
socket.addEventListener('close', e => {
if (e.wasClean) {
console.log(`WebSocket connection closed cleanly, code=` + e.code + `, reason=` + e.reason);
} else {
console.error('WebSocket connection died');
}
document.body.classList.add('disconnected');
});
socket.addEventListener('error', e => {
messageDiv.textContent = "${MR.strings.label_error.localized(locale)}: " + (e.message || e.reason || e);
messageDiv.classList.add('error');
console.error('WebSocket error:', e);
});
/// Page events
const observer = new ResizeObserver(() => {
socket.send(JSON.stringify({ type: 'resize', width: frame.clientWidth, height: frame.clientHeight }));
});
observer.observe(frame);
const frameEvent = (e) => {
// Chrome Android bug, see below
if (e.key === "Unidentified") return;
e.preventDefault();
const rect = frame.getBoundingClientRect();
const clickX = e.clientX !== undefined ? e.clientX - rect.left : 0;
const clickY = e.clientY !== undefined ? e.clientY - rect.top : 0;
socket.send(JSON.stringify({
type: 'event',
eventType: e.type,
clickX,
clickY,
button: e.button,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
altKey: e.altKey,
metaKey: e.metaKey,
key: e.key,
clientX: e.clientX,
clientY: e.clientY,
deltaY: reverseToggle.checked && typeof e.deltaY === 'number' ? -e.deltaY : e.deltaY,
}));
frameInput.focus();
};
const attachEvents = () => {
console.log('Attaching event handlers to new document');
const events = ["click", "mousedown", "mouseup", "mousemove", "wheel", "keydown", "keyup"];
for (const ev of events) {
frameInput.addEventListener(ev, frameEvent, false);
}
let touch = undefined;
frameInput.addEventListener('touchstart', e => {
if (e.touches.length === 1) {
touch = e.touches[0];
}
}, false);
frameInput.addEventListener('touchend', e => {
touch = undefined;
}, false);
frameInput.addEventListener('touchmove', e => {
if (e.touches.length === 1 && touch !== undefined) {
e.preventDefault();
let deltaX = touch.pageX - e.touches[0].pageX;
let deltaY = touch.pageY - e.touches[0].pageY;
console.log(deltaX, deltaY)
if (Math.abs(deltaX) > Math.abs(deltaY)) {
// assume horizontal scroll
socket.send(JSON.stringify({
type: 'event',
eventType: 'wheel',
clickX: e.touches[0].pageX,
clickY: e.touches[0].pageY,
shiftKey: true,
clientX: e.touches[0].clientX,
clientY: e.touches[0].clientY,
deltaY: deltaX,
}));
} else {
socket.send(JSON.stringify({
type: 'event',
eventType: 'wheel',
clickX: e.touches[0].pageX,
clickY: e.touches[0].pageY,
clientX: e.touches[0].clientX,
clientY: e.touches[0].clientY,
deltaY: deltaY,
}));
}
touch = e.touches[0];
}
}, false);
// known bug on Chrome Android:
// https://stackoverflow.com/questions/36753548/keycode-on-android-is-always-229
// on other browsers, the preventDefault above works so we don't get this event
frameInput.addEventListener('input', e => {
e.preventDefault();
socket.send(JSON.stringify({
type: 'event',
eventType: 'keydown',
clickX: 0,
clickY: 0,
key: e.data,
}));
socket.send(JSON.stringify({
type: 'event',
eventType: 'keyup',
clickX: 0,
clickY: 0,
key: e.data,
}));
e.target.value = '';
});
frameInput.addEventListener('contextmenu', e => {
e.preventDefault();
}, false);
};
attachEvents();
frameInput.focus();
} catch (e) {
messageDiv.textContent = "${MR.strings.label_error.localized(locale)}: " + (e.message || e);
messageDiv.classList.add('error');
console.error(e);
}
</script>
</body>
</html>