mirror of
https://github.com/koodo-reader/koodo-reader.git
synced 2026-10-06 12:53:52 -04:00
55 files changed
+661
-373
No files matched your search
@@ -260,6 +260,9 @@ if (!singleInstance) {
|
||||
// Handle deep link passed via second-instance argv
|
||||
const deepLink = argv.find((arg) => arg.startsWith("koodo-reader://"));
|
||||
if (deepLink) {
|
||||
if (deepLink === pendingDeepLink) {
|
||||
pendingDeepLink = null;
|
||||
}
|
||||
handleCallback(deepLink);
|
||||
}
|
||||
});
|
||||
@@ -2125,6 +2128,8 @@ app.on("open-url", (event, url) => {
|
||||
event.preventDefault();
|
||||
handleCallback(url);
|
||||
});
|
||||
var lastDeepLinkUrl = null;
|
||||
var lastDeepLinkAt = 0;
|
||||
const handleCallback = (url) => {
|
||||
try {
|
||||
// 检查 URL 是否有效
|
||||
@@ -2133,6 +2138,16 @@ const handleCallback = (url) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 同一链接 5 秒内重复派发时忽略,避免确认框弹出两次
|
||||
// (Windows 冷启动时 pendingDeepLink 与 second-instance 可能都到达)
|
||||
const now = Date.now();
|
||||
if (url === lastDeepLinkUrl && now - lastDeepLinkAt < 5000) {
|
||||
console.info("Ignored duplicate deep link callback");
|
||||
return;
|
||||
}
|
||||
lastDeepLinkUrl = url;
|
||||
lastDeepLinkAt = now;
|
||||
|
||||
// 解析 URL
|
||||
const parsedUrl = new URL(url);
|
||||
const code = parsedUrl.searchParams.get("code");
|
||||
|
||||
+2
-2
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "koodo-reader",
|
||||
"main": "main.js",
|
||||
"version": "2.4.5",
|
||||
"version": "2.4.6",
|
||||
"description": "Koodo Reader is a cross-platform ebook reader",
|
||||
"author": {
|
||||
"name": "App by Troye",
|
||||
"email": "feedback@koodoreader.com"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"node": ">=22.12.0",
|
||||
"npm": ">=6.0.0"
|
||||
},
|
||||
"repository": "https://github.com/koodo-reader/koodo-reader",
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// -*- coding: utf-8 -*-
|
||||
/**
|
||||
* 提取每个语言文件中value和en.json中同一个键的value相同的词条,
|
||||
* 并将结果输出到 untranslated/ 目录下的对应文件中。
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function loadJson(filepath) {
|
||||
const content = fs.readFileSync(filepath, "utf-8");
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
function saveJson(filepath, data) {
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
fs.writeFileSync(filepath, content, "utf-8");
|
||||
}
|
||||
|
||||
function extractUntranslated() {
|
||||
const localesDir = "../src/assets/locales";
|
||||
const outputDir = "../untranslated";
|
||||
|
||||
// 创建输出目录
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 加载 en.json 作为参考
|
||||
const enFilepath = path.join(localesDir, "en.json");
|
||||
const enData = loadJson(enFilepath);
|
||||
|
||||
// 获取所有符合条件的语言文件
|
||||
const langFiles = fs
|
||||
.readdirSync(localesDir)
|
||||
.filter(
|
||||
(f) => f.endsWith(".json") && f !== "en.json" && !f.startsWith("index")
|
||||
)
|
||||
.sort();
|
||||
|
||||
console.info(`${"文件".padEnd(15)} ${"未翻译条目数".padStart(10)}`);
|
||||
console.info("-".repeat(30));
|
||||
|
||||
for (const langFile of langFiles) {
|
||||
const filepath = path.join(localesDir, langFile);
|
||||
const data = loadJson(filepath);
|
||||
|
||||
// 提取value与en.json中同一键的value相同的条目
|
||||
const untranslated = Object.fromEntries(
|
||||
Object.entries(data).filter(([k, v]) => enData[k] === v)
|
||||
);
|
||||
|
||||
const outPath = path.join(outputDir, langFile);
|
||||
saveJson(outPath, untranslated);
|
||||
|
||||
const count = Object.keys(untranslated).length;
|
||||
console.info(
|
||||
`${langFile.padEnd(15)} ${String(count).padStart(10)} 条 -> ${outPath}`
|
||||
);
|
||||
}
|
||||
console.info(`提取完成!结果已保存到 ${outputDir}/ 目录。`);
|
||||
}
|
||||
|
||||
extractUntranslated();
|
||||
+20
-9
@@ -2,13 +2,19 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const localesPath = path.join(__dirname, "../src/assets/locales");
|
||||
const folders = fs.readdirSync(localesPath);
|
||||
const files = fs.readdirSync(localesPath);
|
||||
let resources = [];
|
||||
for (let index = 0; index < folders.length; index++) {
|
||||
const folder = folders[index];
|
||||
resources.push(`../src/assets/locales/${folder}`);
|
||||
for (let index = 0; index < files.length; index++) {
|
||||
const file = files[index];
|
||||
resources.push(`../src/assets/locales/${file}`);
|
||||
}
|
||||
console.info(resources);
|
||||
let targets = [];
|
||||
for (let index = 0; index < files.length; index++) {
|
||||
const file = files[index];
|
||||
targets.push(`../scripts/missing/${file}`);
|
||||
}
|
||||
console.info(targets);
|
||||
|
||||
// find the missing terms in the english
|
||||
const zhdataRaw = fs.readFileSync(
|
||||
@@ -62,12 +68,17 @@ for (let index = 0; index < resources.length; index++) {
|
||||
missingTerms[term] = referData[term];
|
||||
}
|
||||
}
|
||||
// console.info(missingTerms);
|
||||
const mergedObj = Object.assign({}, targetData, missingTerms);
|
||||
|
||||
if (
|
||||
resources[index].includes("en.json") ||
|
||||
resources[index].includes("zh-CN.json")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
console.info(missingTerms);
|
||||
const target = targets[index];
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, resource),
|
||||
JSON.stringify(mergedObj, null, 2)
|
||||
path.join(__dirname, target),
|
||||
JSON.stringify(missingTerms, null, 2)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error reading JSON file:", error);
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const translatedDir = path.join(__dirname, "../translated");
|
||||
const localesDir = path.join(__dirname, "../src/assets", "locales");
|
||||
|
||||
const files = fs.readdirSync(translatedDir).filter((f) => f.endsWith(".json"));
|
||||
|
||||
let totalMerged = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const translatedPath = path.join(translatedDir, file);
|
||||
const localesPath = path.join(localesDir, file);
|
||||
|
||||
if (!fs.existsSync(localesPath)) {
|
||||
console.warn(`⚠️ Skipped: ${file} (not found in locales)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const translated = JSON.parse(fs.readFileSync(translatedPath, "utf-8"));
|
||||
const locales = JSON.parse(fs.readFileSync(localesPath, "utf-8"));
|
||||
|
||||
const mergedCount = Object.keys(translated).length;
|
||||
const merged = { ...locales, ...translated };
|
||||
|
||||
fs.writeFileSync(localesPath, JSON.stringify(merged, null, 2), "utf-8");
|
||||
|
||||
totalMerged += mergedCount;
|
||||
console.info(`✅ ${file}: merged ${mergedCount} entries`);
|
||||
}
|
||||
|
||||
console.info(`\nDone. Total entries merged: ${totalMerged}`);
|
||||
+1
-1
File diff suppressed because one or more lines are too long.
File diff suppressed because one or more lines are too long.
Vendored
+1
-1
File diff suppressed because one or more lines are too long.
@@ -54,6 +54,8 @@
|
||||
"Dictionary": "Dictionary",
|
||||
"Shadow": "Shadow",
|
||||
"Import failed": "Import failed",
|
||||
"Do you want to download and import the book from this URL?": "Do you want to download and import the book from this URL?",
|
||||
"Import cancelled": "Import cancelled",
|
||||
"New version available": "New version available",
|
||||
"New version": "New version",
|
||||
"Customize": "Customize",
|
||||
@@ -323,6 +325,9 @@
|
||||
"Copying successful": "Copying successful",
|
||||
"Copy ": "Copy",
|
||||
"Text color": "Text color",
|
||||
"Eraser": "Eraser",
|
||||
"Eraser size": "Eraser size",
|
||||
"Quick erase": "Quick erase",
|
||||
"Export": "Export",
|
||||
"Restore": "Restore",
|
||||
"Publisher": "Publisher",
|
||||
@@ -336,6 +341,13 @@
|
||||
"Do you want to open this link in browser": "Do you want to open this link in a browser",
|
||||
"WebDAV Info": "WebDAV is an easy-to-use and efficient backup solution, we recommend using WebDAV service provided by NextCloud",
|
||||
"Please wait": "Please wait",
|
||||
"Report reason": "Report reason",
|
||||
"Thank you for your feedback": "Thank you for your feedback",
|
||||
"Inaccurate or misleading information": "Inaccurate or misleading information",
|
||||
"Inappropriate or offensive content": "Inappropriate or offensive content",
|
||||
"Irrelevant or off-topic response": "Irrelevant or off-topic response",
|
||||
"Privacy concern": "Privacy concern",
|
||||
"Other": "Other",
|
||||
"Server address": "Server address",
|
||||
"Username": "Username",
|
||||
"Password": "Password",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"Horizontal": "横排",
|
||||
"You can add models manually by selecting the custom model": "您可以通过选择自定义模型来手动添加模型",
|
||||
"Vertical": "竖排",
|
||||
"Priority support": "优先客服支持",
|
||||
"Synchronisation successful": "同步成功",
|
||||
"Permanently Delete": "永久删除",
|
||||
"Default search engine": "默认搜索引擎",
|
||||
@@ -261,6 +262,7 @@
|
||||
"Audio loading failed, stopped playback": "音频加载失败,已停止播放",
|
||||
"The text is too long to analyze": "文本过长无法分析",
|
||||
"Analysis failed": "分析失败",
|
||||
"Unlock Pro features on all Platform": "一次购买全平台通用",
|
||||
"Analyzing roles, please wait...": "正在分析角色,请稍候...",
|
||||
"Compare Free and Pro features": "免费版和专业版功能对比",
|
||||
"Recommended (use with Nutstore)": "推荐使用(配合坚果云)",
|
||||
@@ -469,7 +471,13 @@
|
||||
"Highlight": "高亮",
|
||||
"Copying successful": "复制成功",
|
||||
"Copy ": "复制",
|
||||
"Do you want to download and import the book from this URL?": "您是否要从此 URL 下载并导入图书?",
|
||||
"No path found for this book": "未找到此图书的路径",
|
||||
"Import cancelled": "导入已取消",
|
||||
"Text color": "文字颜色",
|
||||
"Eraser": "橡皮擦",
|
||||
"Eraser size": "橡皮擦大小",
|
||||
"Quick erase": "快速擦除",
|
||||
"Export": "导出",
|
||||
"Publisher": "出版商",
|
||||
"Added on": "添加于",
|
||||
@@ -478,6 +486,13 @@
|
||||
"WebDAV Info": "WebDAV是一种简单高效的数据备份方式,这里推荐使用坚果云提供的WebDAV服务",
|
||||
"The Koodo Reader Docker version does not support the data source feature by default. You need to modify the configuration parameters during deployment to manually enable it. Also due to browser's security restrictions, the Docker service must be accessed via HTTPS protocol when you're visiting Koodo Reader via HTTPS protocol.": "Koodo Reader Docker 版默认不支持数据源功能,您需要在部署时修改配置参数手动开启。由于浏览器的安全限制,当您通过 HTTPS 协议访问 Koodo Reader 时, Docker 服务也必须通过 HTTPS 协议访问。",
|
||||
"Please wait": "请稍侯",
|
||||
"Report reason": "举报原因",
|
||||
"Thank you for your feedback": "感谢您的反馈",
|
||||
"Inaccurate or misleading information": "不准确或具有误导性的信息",
|
||||
"Inappropriate or offensive content": "不适当或冒犯性的内容",
|
||||
"Irrelevant or off-topic response": "不相关或离题的回答",
|
||||
"Privacy concern": "隐私问题",
|
||||
"Other": "其他",
|
||||
"Select server region": "选择服务器地区",
|
||||
"Server region": "服务器地区",
|
||||
"Global": "全球",
|
||||
@@ -1370,4 +1385,4 @@
|
||||
"Reading ruler height (lines)": "阅读标尺高度(行)",
|
||||
"Reading ruler background opacity": "阅读标尺背景透明度",
|
||||
"ISBN": "ISBN"
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,13 @@
|
||||
"Do you want to open this link in browser": "是否使用瀏覽器打開此連結",
|
||||
"WebDAV Info": "WebDAV資訊",
|
||||
"Please wait": "請稍侯",
|
||||
"Report reason": "檢舉原因",
|
||||
"Thank you for your feedback": "感謝您的回饋",
|
||||
"Inaccurate or misleading information": "不準確或具有誤導性的資訊",
|
||||
"Inappropriate or offensive content": "不適當或冒犯性的內容",
|
||||
"Irrelevant or off-topic response": "不相關或離題的回答",
|
||||
"Privacy concern": "隱私問題",
|
||||
"Other": "其他",
|
||||
"Server address": "伺服器地址",
|
||||
"Username": "用戶名",
|
||||
"Password": "密碼",
|
||||
|
||||
Binary file not shown.
@@ -117,4 +117,7 @@
|
||||
<glyph unicode="" glyph-name="font" d="M276.208 800l-259.888-688h85.536l66.48 176h285.344l66.48-176h85.536l-259.936 688h-69.568zM198.56 368l112.448 297.68 112.432-297.68h-224.88zM671.328 604l27.84-74.992c55.232 20.288 164.4 52.256 206.88 11.92 15.28-14.56 21.952-25.152 21.952-76.928v-276.512c-51.52-12.56-138.8-23.216-187.456 14.752-54.4 42.4-52.544 193.52 123.456 161.2v81.2c-112 14.416-184.544-17.552-219.44-93.12-58.352-126.288 27.44-336.896 363.44-222.72v349.344c0 124.4-96.864 215.072-336.688 125.856z" />
|
||||
<glyph unicode="" glyph-name="move" d="M341.333 106.667c-47.128 0-85.333-38.205-85.333-85.333s38.205-85.333 85.333-85.333v0c47.128 0 85.333 38.205 85.333 85.333s-38.205 85.333-85.333 85.333v0zM341.333 533.333c-47.128 0-85.333-38.205-85.333-85.333s38.205-85.333 85.333-85.333v0c47.128 0 85.333 38.205 85.333 85.333s-38.205 85.333-85.333 85.333v0zM341.333 960c-47.128 0-85.333-38.205-85.333-85.333s38.205-85.333 85.333-85.333v0c47.128 0 85.333 38.205 85.333 85.333s-38.205 85.333-85.333 85.333v0zM682.667 106.667c-47.128 0-85.333-38.205-85.333-85.333s38.205-85.333 85.333-85.333v0c47.128 0 85.333 38.205 85.333 85.333s-38.205 85.333-85.333 85.333v0zM682.667 533.333c-47.128 0-85.333-38.205-85.333-85.333s38.205-85.333 85.333-85.333v0c47.128 0 85.333 38.205 85.333 85.333s-38.205 85.333-85.333 85.333v0zM682.667 960c-47.128 0-85.333-38.205-85.333-85.333s38.205-85.333 85.333-85.333v0c47.128 0 85.333 38.205 85.333 85.333s-38.205 85.333-85.333 85.333v0z" />
|
||||
<glyph unicode="" glyph-name="sidebar" d="M810.667 874.667c70.692 0 128-57.308 128-128v0-597.333c0-70.692-57.308-128-128-128v0h-597.333c-70.692 0-128 57.308-128 128v0 597.333c0 70.692 57.308 128 128 128v0h597.333zM341.333 789.333h-128l-5.803-0.427c-18.983-2.626-33.832-17.46-36.454-36.213l-0.026-0.224-0.384-5.803v-597.333l0.427-5.803c2.626-18.983 17.46-33.832 36.213-36.454l0.224-0.026 5.803-0.384h128v682.667zM810.667 789.333h-384v-682.667h384l5.803 0.427c18.983 2.626 33.832 17.46 36.454 36.213l0.026 0.224 0.384 5.803v597.333l-0.427 5.803c-2.897 20.925-20.666 36.864-42.158 36.864-0.029 0-0.057 0-0.086 0h0.004z" />
|
||||
<glyph unicode="" glyph-name="eraser-line" d="M366.336 582.229l-211.2-211.2 221.611-221.696 99.883 0.085 161.237 161.237-271.531 271.531zM426.667 642.56l271.531-271.531 120.661 120.661-271.531 271.531-120.661-120.661zM597.333 149.461h298.667v-85.333l-554.581-0.085-276.779 276.821c-7.718 7.721-12.492 18.386-12.492 30.165s4.774 22.445 12.492 30.165l452.48 452.523c7.721 7.718 18.386 12.492 30.165 12.492s22.445-4.774 30.165-12.492l331.904-331.861c7.718-7.721 12.492-18.386 12.492-30.165s-4.774-22.445-12.492-30.165l-312.021-312.064z" />
|
||||
<glyph unicode="" glyph-name="account" d="M880.182-27.568h-736.364c-45.113 0-81.818 34.686-81.818 77.323v108.725c0 122.986 110.113 223.042 245.455 223.042h409.090c135.342 0 245.455-100.055 245.455-223.042v-108.726c0-42.636-36.704-77.322-81.818-77.322zM143.818 54.629l736.363-0.379v104.23c0 77.873-73.408 141.224-163.637 141.224h-409.090c-90.228 0-163.636-63.351-163.636-141.224v-103.851zM511.67 412.204c-140.984 0-255.682 114.697-255.682 255.682s114.697 255.682 255.682 255.682 255.682-114.697 255.682-255.682c0.001-140.985-114.697-255.682-255.682-255.682zM511.67 841.75c-95.871 0-173.864-77.993-173.864-173.864s77.994-173.864 173.864-173.864 173.864 77.993 173.864 173.864-77.993 173.864-173.864 173.864z" />
|
||||
<glyph unicode="🌡" glyph-name="report" d="M901.3 858.6c-5.5 2.4-25.1 8.9-44.1-8-1.4-1.3-3-2.7-5-4.6-67.7-63.9-173-124.4-290.1-50.5-59.7 41.3-118.9 70.3-175.9 86.2-47.8 13.3-94.4 17.6-138.5 12.6-70.7-8-115.9-37.7-132.4-50.6-10.1-7.3-16.7-19.1-16.7-32.6v-9.7c-0.1-0.9-0.1-1.8-0.1-2.7v-759c0-22.1 17.9-40 40-40s40 17.9 40 40v264.2c46.5 21.1 165.8 52.2 339-66 0.6-0.4 1.3-0.8 1.9-1.2 45.8-27.5 94.9-41.4 146.6-41.4 11.4 0 23 0.7 34.6 2 46.3 5.4 93.3 21.4 139.4 47.6 23 13 41.2 26.1 52.5 34.8 6.7 5.2 11.7 9.4 14.7 12 6.9 5.9 18.3 15.9 18.3 34.3v495.8c0 15.9-9.5 30.5-24.2 36.8zM141.8 851c-1 0.1-2.1 0.1-3.2 0.1h-0.1c1.1 0 2.2-0.1 3.3-0.1zM136.3 851c-0.5 0-1.1-0.1-1.6-0.1 0.5 0 1.1 0.1 1.6 0.1zM133.6 850.8c-0.8-0.1-1.5-0.2-2.2-0.3 0.8 0.1 1.5 0.2 2.2 0.3zM131 850.4c-3.8-0.7-7.4-2-10.7-3.7 3.4 1.7 7 2.9 10.7 3.7zM120.1 846.6c-0.6-0.3-1.2-0.7-1.8-1 0.6 0.3 1.2 0.7 1.8 1zM845.5 344.5c-16.3-12.7-48.2-35.2-88.9-51-70.7-27.5-136.3-23.8-195 11.2-57.2 38.8-114.6 66.7-170.6 82.7-46.8 13.4-92.9 18.7-137.1 15.7-29.4-2-54.5-7.3-75.3-13.7v400.4c15.6 9 41.9 20.8 78.2 24.9 77.1 8.7 167.2-20.9 260.5-85.5 0.4-0.3 0.9-0.6 1.3-0.9 44.6-28.3 91.4-44 139.1-46.6 39.4-2.2 79.3 4.5 118.6 19.9 26.3 10.3 49.6 23.4 69.2 36.5v-393.6z" />
|
||||
</font></defs></svg>
|
||||
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 111 KiB |
Binary file not shown.
Binary file not shown.
+17
-10
@@ -1,20 +1,18 @@
|
||||
@font-face {
|
||||
font-family: "icomoon";
|
||||
src: url("fonts/icomoon.eot?d39nd");
|
||||
src:
|
||||
url("fonts/icomoon.eot?d39nd#iefix") format("embedded-opentype"),
|
||||
url("fonts/icomoon.ttf?d39nd") format("truetype"),
|
||||
url("fonts/icomoon.woff?d39nd") format("woff"),
|
||||
url("fonts/icomoon.svg?d39nd#icomoon") format("svg");
|
||||
font-family: 'icomoon';
|
||||
src: url('fonts/icomoon.eot?d91hll');
|
||||
src: url('fonts/icomoon.eot?d91hll#iefix') format('embedded-opentype'),
|
||||
url('fonts/icomoon.ttf?d91hll') format('truetype'),
|
||||
url('fonts/icomoon.woff?d91hll') format('woff'),
|
||||
url('fonts/icomoon.svg?d91hll#icomoon') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
[class^="icon-"],
|
||||
[class*=" icon-"] {
|
||||
[class^="icon-"], [class*=" icon-"] {
|
||||
/* use !important to prevent issues with browser extensions that change fonts */
|
||||
font-family: "icomoon" !important;
|
||||
font-family: 'icomoon' !important;
|
||||
speak: never;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
@@ -27,6 +25,15 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-account:before {
|
||||
content: "\e96f";
|
||||
}
|
||||
.icon-eraser-line:before {
|
||||
content: "\e96e";
|
||||
}
|
||||
.icon-report:before {
|
||||
content: "\1f321";
|
||||
}
|
||||
.icon-sidebar:before {
|
||||
content: "\e96d";
|
||||
}
|
||||
|
||||
@@ -62,10 +62,14 @@ const BookCoverItem: React.FC<BookCoverProps> = (props) => {
|
||||
|
||||
const percentage = getPercentage();
|
||||
|
||||
var htmlString = props.book.description;
|
||||
var div = document.createElement("div");
|
||||
div.innerHTML = htmlString;
|
||||
var textContent = div.textContent || div.innerText;
|
||||
// Do NOT assign book-supplied HTML to innerHTML, even on a detached element,
|
||||
// as that parses and fires inline event handlers (see CVE class in epubs).
|
||||
// Use DOMParser instead, which never executes scripts or event handlers.
|
||||
const textContent = props.book.description
|
||||
? (new DOMParser()
|
||||
.parseFromString(props.book.description, "text/html")
|
||||
.documentElement.textContent?.trim() ?? "")
|
||||
: "";
|
||||
|
||||
const actionProps = { left, top };
|
||||
|
||||
|
||||
@@ -76,7 +76,9 @@ class AddDialog extends Component<AddDialogProps, AddDialogState> {
|
||||
toast.success(this.props.t("Addition successful"));
|
||||
this.props.handleActionDialog(false);
|
||||
this.props.handleMode("shelf");
|
||||
this.props.handleShelf(shelfTitle);
|
||||
setTimeout(() => {
|
||||
this.props.handleShelf(shelfTitle);
|
||||
}, 1000);
|
||||
};
|
||||
handleChange = (shelfTitle: string) => {
|
||||
if (shelfTitle === "New shelf") {
|
||||
|
||||
@@ -184,6 +184,36 @@
|
||||
.annotation-text-size-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.annotation-quick-erase-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.annotation-quick-erase-container:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.annotation-quick-erase-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
.annotation-quick-erase-switch {
|
||||
display: flex;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--text-color, #2c2c2c);
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.annotation-quick-erase-button {
|
||||
display: inline-block;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
margin: 2px;
|
||||
background-color: var(--background-color, #fff);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.annotation-text-font-select {
|
||||
width: 100% !important;
|
||||
left: 0 !important;
|
||||
|
||||
@@ -6,6 +6,10 @@ import { ConfigService } from "../../../assets/lib/kookit-extra-browser.min";
|
||||
import {
|
||||
BRUSH_COLORS,
|
||||
BRUSH_WIDTHS,
|
||||
ERASER_WIDTH_DEFAULT,
|
||||
ERASER_WIDTH_MAX,
|
||||
ERASER_WIDTH_MIN,
|
||||
ERASER_WIDTH_STEP,
|
||||
HIGHLIGHTER_COLORS,
|
||||
HIGHLIGHTER_WIDTHS,
|
||||
SHAPE_TYPES,
|
||||
@@ -51,6 +55,12 @@ class AnnotationDialog extends React.Component<
|
||||
ConfigService.getReaderConfig("annotationShapeWidth") ||
|
||||
BRUSH_WIDTHS[1] + ""
|
||||
),
|
||||
annotationEraserWidth: parseFloat(
|
||||
ConfigService.getReaderConfig("annotationEraserWidth") ||
|
||||
ERASER_WIDTH_DEFAULT + ""
|
||||
),
|
||||
annotationQuickErase:
|
||||
ConfigService.getReaderConfig("annotationQuickErase") || "no",
|
||||
annotationTextSize: parseFloat(
|
||||
ConfigService.getReaderConfig("annotationTextSize") || "24"
|
||||
),
|
||||
@@ -142,6 +152,27 @@ class AnnotationDialog extends React.Component<
|
||||
});
|
||||
ConfigService.setReaderConfig("annotationShapeWidth", width + "");
|
||||
};
|
||||
handleSelectEraserWidth = (width: number) => {
|
||||
this.setState({ annotationEraserWidth: width });
|
||||
this.props.htmlBook.rendition.applyAnnotationConfig({
|
||||
eraserWidth: width,
|
||||
});
|
||||
ConfigService.setReaderConfig("annotationEraserWidth", width + "");
|
||||
};
|
||||
handleEraserWidthRelease = () => {
|
||||
ConfigService.setReaderConfig(
|
||||
"annotationEraserWidth",
|
||||
this.state.annotationEraserWidth + ""
|
||||
);
|
||||
};
|
||||
handleToggleQuickErase = () => {
|
||||
const next = this.state.annotationQuickErase === "yes" ? "no" : "yes";
|
||||
this.setState({ annotationQuickErase: next });
|
||||
this.props.htmlBook.rendition.applyAnnotationConfig({
|
||||
quickErase: next,
|
||||
});
|
||||
ConfigService.setReaderConfig("annotationQuickErase", next);
|
||||
};
|
||||
handleSelectTextSize = (size: number) => {
|
||||
this.setState({ annotationTextSize: size });
|
||||
this.props.htmlBook.rendition.applyAnnotationConfig({
|
||||
@@ -233,6 +264,8 @@ class AnnotationDialog extends React.Component<
|
||||
annotationShapeType,
|
||||
annotationShapeColor,
|
||||
annotationShapeWidth,
|
||||
annotationEraserWidth,
|
||||
annotationQuickErase,
|
||||
annotationTextSize,
|
||||
annotationTextFont,
|
||||
annotationTextColor,
|
||||
@@ -324,6 +357,20 @@ class AnnotationDialog extends React.Component<
|
||||
>
|
||||
<span className="icon-font annotation-dialog-tab-icon"></span>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
annotationStyle === "eraser"
|
||||
? "annotation-dialog-tab active-annotation-dialog-tab"
|
||||
: "annotation-dialog-tab"
|
||||
}
|
||||
onClick={() => this.handleSelectTab("eraser")}
|
||||
title={this.props.t("Eraser")}
|
||||
>
|
||||
<span
|
||||
className="icon-eraser-line annotation-dialog-tab-icon"
|
||||
style={{ fontSize: "20px" }}
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{annotationStyle === "brush" ? (
|
||||
@@ -531,6 +578,56 @@ class AnnotationDialog extends React.Component<
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
) : annotationStyle === "eraser" ? (
|
||||
<>
|
||||
<div className="annotation-dialog-section annotation-text-size-section">
|
||||
<div className="annotation-opacity-label">
|
||||
<span>{this.props.t("Eraser size")}</span>
|
||||
<span className="annotation-opacity-value">
|
||||
{annotationEraserWidth}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={ERASER_WIDTH_MIN}
|
||||
max={ERASER_WIDTH_MAX}
|
||||
step={ERASER_WIDTH_STEP}
|
||||
value={annotationEraserWidth}
|
||||
className="annotation-opacity-slider"
|
||||
onChange={(e) =>
|
||||
this.handleSelectEraserWidth(parseFloat(e.target.value))
|
||||
}
|
||||
onPointerUp={this.handleEraserWidthRelease}
|
||||
onMouseUp={this.handleEraserWidthRelease}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="annotation-quick-erase-container"
|
||||
onClick={this.handleToggleQuickErase}
|
||||
>
|
||||
<span className="annotation-quick-erase-title">
|
||||
{this.props.t("Quick erase")}
|
||||
</span>
|
||||
<span
|
||||
className="annotation-quick-erase-switch"
|
||||
style={
|
||||
annotationQuickErase === "yes"
|
||||
? { opacity: 1 }
|
||||
: { opacity: 0.4 }
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="annotation-quick-erase-button"
|
||||
style={
|
||||
annotationQuickErase === "yes"
|
||||
? { transform: "translateX(20px)" }
|
||||
: { transform: "translateX(0px)" }
|
||||
}
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="annotation-dialog-section annotation-text-size-section">
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface AnnotationDialogState {
|
||||
annotationShapeType: string;
|
||||
annotationShapeColor: string;
|
||||
annotationShapeWidth: number;
|
||||
annotationEraserWidth: number;
|
||||
annotationQuickErase: string;
|
||||
annotationTextSize: number;
|
||||
annotationTextFont: string;
|
||||
annotationTextColor: string;
|
||||
|
||||
@@ -66,7 +66,7 @@ class PopupOptionDialog extends React.Component<
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
className="backup-page-container popup-option-dialog-container"
|
||||
className="edit-dialog-container popup-option-dialog-container"
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -102,7 +102,7 @@ class PopupOptionDialog extends React.Component<
|
||||
style={item.enabled ? {} : { opacity: 0.6 }}
|
||||
>
|
||||
<span
|
||||
className={`icon-${item.icon} popup-option-dialog-item-icon`}
|
||||
className={`icon-${item.icon} ${item.name}-icon popup-option-dialog-item-icon`}
|
||||
></span>
|
||||
<span className="popup-option-dialog-item-label">
|
||||
{this.props.t(item.title)}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
.popup-option-dialog-container {
|
||||
height: 520px;
|
||||
top: calc(50% - 260px);
|
||||
}
|
||||
|
||||
.popup-option-dialog-item {
|
||||
gap: 12px;
|
||||
padding-right: 15px;
|
||||
|
||||
@@ -112,6 +112,7 @@ class SettingDialog extends React.Component<
|
||||
|
||||
{/* 第一组 */}
|
||||
<div className="setting-dialog-sidebar-group">
|
||||
{this.renderSidebarItem("account", "icon-user", "Account", "18px")}
|
||||
{this.renderSidebarItem("general", "icon-setting", "General", "")}
|
||||
{this.renderSidebarItem("data", "icon-archive", "Data", "15px")}
|
||||
{this.renderSidebarItem(
|
||||
@@ -140,7 +141,6 @@ class SettingDialog extends React.Component<
|
||||
"More settings",
|
||||
"13px"
|
||||
)}
|
||||
{this.renderSidebarItem("account", "icon-user", "Account", "18px")}
|
||||
{this.renderSidebarItem("about", "icon-detail", "About", "18px")}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import supportAnimation from "../../../assets/lotties/support.json";
|
||||
import exitAnimation from "../../../assets/lotties/exit.json";
|
||||
import {
|
||||
getServerRegion,
|
||||
getWebsiteLang,
|
||||
getWebsiteUrl,
|
||||
handleContextMenu,
|
||||
openInBrowser,
|
||||
@@ -364,13 +365,7 @@ class SupportDialog extends React.Component<
|
||||
let deviceUuid =
|
||||
await TokenService.getFingerprint();
|
||||
openInBrowser(
|
||||
getWebsiteUrl() +
|
||||
(ConfigService.getReaderConfig(
|
||||
"lang"
|
||||
).startsWith("zh")
|
||||
? "/zh"
|
||||
: "/en") +
|
||||
"/pricing?temp_token=" +
|
||||
getWebsiteUrl() + getWebsiteLang() + "/pricing?temp_token=" +
|
||||
tempToken +
|
||||
"&device_uuid=" +
|
||||
deviceUuid
|
||||
|
||||
@@ -7,6 +7,7 @@ import Lottie from "lottie-react";
|
||||
import animationNew from "../../../assets/lotties/new.json";
|
||||
import {
|
||||
compareVersions,
|
||||
getWebsiteLang,
|
||||
getWebsiteUrl,
|
||||
openExternalUrl,
|
||||
} from "../../../utils/common";
|
||||
@@ -208,17 +209,9 @@ class UpdateInfo extends React.Component<UpdateInfoProps, UpdateInfoState> {
|
||||
}, 500);
|
||||
}
|
||||
} else {
|
||||
let lang = "en";
|
||||
if (
|
||||
ConfigService.getReaderConfig("lang") &&
|
||||
ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
) {
|
||||
lang = "zh";
|
||||
}
|
||||
openExternalUrl(
|
||||
getWebsiteUrl() +
|
||||
"/" +
|
||||
lang +
|
||||
getWebsiteLang() +
|
||||
"/download" +
|
||||
"?version=" +
|
||||
(this.state.updateLog.stable === "yes"
|
||||
@@ -239,17 +232,9 @@ class UpdateInfo extends React.Component<UpdateInfoProps, UpdateInfoState> {
|
||||
<div
|
||||
className="new-version-skip"
|
||||
onClick={() => {
|
||||
let lang = "en";
|
||||
if (
|
||||
ConfigService.getReaderConfig("lang") &&
|
||||
ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
) {
|
||||
lang = "zh";
|
||||
}
|
||||
openExternalUrl(
|
||||
getWebsiteUrl() +
|
||||
"/" +
|
||||
lang +
|
||||
getWebsiteLang() +
|
||||
"/download" +
|
||||
"?version=" +
|
||||
(this.state.updateLog.stable === "yes"
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getTextRules,
|
||||
supportedFormats,
|
||||
throttle,
|
||||
vexComfirmAsync,
|
||||
vexPromptAsync,
|
||||
} from "../../utils/common";
|
||||
import DatabaseService from "../../utils/storage/databaseService";
|
||||
@@ -42,6 +43,13 @@ const supportedFormatsAccept = supportedFormats.reduce<
|
||||
}, {});
|
||||
declare var window: any;
|
||||
let clickFilePath = "";
|
||||
const escapeHtml = (text: string) =>
|
||||
text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
// Comic 封面规则与 kookit comic-book.js 保持一致:取自然排序后的第一张图片
|
||||
const COMIC_IMAGE_EXTS = [
|
||||
".jpg",
|
||||
@@ -64,6 +72,7 @@ const getComicImageExt = (name: string) => {
|
||||
|
||||
class ImportLocal extends React.Component<ImportLocalProps, ImportLocalState> {
|
||||
resizeHandler: (() => void) | null = null;
|
||||
importUrlHandler: ((config: any) => void) | null = null;
|
||||
|
||||
constructor(props: ImportLocalProps) {
|
||||
super(props);
|
||||
@@ -98,11 +107,26 @@ class ImportLocal extends React.Component<ImportLocalProps, ImportLocalState> {
|
||||
false
|
||||
);
|
||||
|
||||
ipcRenderer.on("import-url-from-link", (config: any) => {
|
||||
this.importUrlHandler = async (config: any) => {
|
||||
const rawUrl = config?.url;
|
||||
if (!rawUrl || typeof rawUrl !== "string") return;
|
||||
const confirmed = await vexComfirmAsync(
|
||||
this.props.t(
|
||||
"Do you want to download and import the book from this URL?"
|
||||
) +
|
||||
"<br><span style='word-break:break-all'>" +
|
||||
escapeHtml(rawUrl) +
|
||||
"</span>",
|
||||
"Confirm",
|
||||
"Cancel"
|
||||
);
|
||||
if (!confirmed) {
|
||||
toast.error(this.props.t("Import cancelled"));
|
||||
return;
|
||||
}
|
||||
this.handleURLImport(undefined as any, rawUrl);
|
||||
});
|
||||
};
|
||||
ipcRenderer.on("import-url-from-link", this.importUrlHandler);
|
||||
}
|
||||
this.resizeHandler = throttle(() => {
|
||||
this.setState({ width: document.body.clientWidth });
|
||||
@@ -111,6 +135,13 @@ class ImportLocal extends React.Component<ImportLocalProps, ImportLocalState> {
|
||||
this.props.handleImportBookFunc(this.getMd5WithBrowser);
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this.importUrlHandler) {
|
||||
window.electronAPI.removeListener(
|
||||
"import-url-from-link",
|
||||
this.importUrlHandler
|
||||
);
|
||||
this.importUrlHandler = null;
|
||||
}
|
||||
if (this.resizeHandler) {
|
||||
window.removeEventListener("resize", this.resizeHandler);
|
||||
this.resizeHandler = null;
|
||||
|
||||
@@ -8,11 +8,15 @@ import {
|
||||
import Parser from "html-react-parser";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Trans } from "react-i18next";
|
||||
import { handleContextMenu } from "../../../utils/common";
|
||||
import {
|
||||
handleContextMenu,
|
||||
REPORT_REASONS,
|
||||
vexSelectAsync,
|
||||
} from "../../../utils/common";
|
||||
import toast from "react-hot-toast";
|
||||
import { saveAs } from "file-saver";
|
||||
import { getAnswerStream } from "../../../utils/request/reader";
|
||||
import { chatStream } from "../../../utils/request/common";
|
||||
import { chatStream, llmReport } from "../../../utils/request/common";
|
||||
import { marked } from "marked";
|
||||
import { sampleQuestion } from "../../../constants/settingList";
|
||||
class PopupAssist extends React.Component<PopupAssistProps, PopupAssistState> {
|
||||
@@ -160,7 +164,10 @@ class PopupAssist extends React.Component<PopupAssistProps, PopupAssistState> {
|
||||
aiService: "official-ai-assistant-plugin",
|
||||
isAddNew: false,
|
||||
});
|
||||
ConfigService.setReaderConfig("aiService", "official-ai-assistant-plugin");
|
||||
ConfigService.setReaderConfig(
|
||||
"aiService",
|
||||
"official-ai-assistant-plugin"
|
||||
);
|
||||
} else {
|
||||
this.setState({
|
||||
isAddNew: true,
|
||||
@@ -258,7 +265,8 @@ class PopupAssist extends React.Component<PopupAssistProps, PopupAssistState> {
|
||||
try {
|
||||
if (
|
||||
ConfigService.getReaderConfig("aiService") &&
|
||||
ConfigService.getReaderConfig("aiService") === "custom-ai-assistant-plugin"
|
||||
ConfigService.getReaderConfig("aiService") ===
|
||||
"custom-ai-assistant-plugin"
|
||||
) {
|
||||
let plugin = this.props.plugins.find(
|
||||
(item) => item.key === "custom-ai-assistant-plugin"
|
||||
@@ -336,7 +344,8 @@ class PopupAssist extends React.Component<PopupAssistProps, PopupAssistState> {
|
||||
}
|
||||
} else if (
|
||||
ConfigService.getReaderConfig("aiService") &&
|
||||
ConfigService.getReaderConfig("aiService") !== "official-ai-assistant-plugin"
|
||||
ConfigService.getReaderConfig("aiService") !==
|
||||
"official-ai-assistant-plugin"
|
||||
) {
|
||||
} else if (this.props.isAuthed) {
|
||||
let plugin = this.props.plugins.find(
|
||||
@@ -412,10 +421,7 @@ class PopupAssist extends React.Component<PopupAssistProps, PopupAssistState> {
|
||||
}
|
||||
};
|
||||
handleChangeAiService = (aiService: string) => {
|
||||
if (
|
||||
aiService === "official-ai-assistant-plugin" &&
|
||||
!this.props.isAuthed
|
||||
) {
|
||||
if (aiService === "official-ai-assistant-plugin" && !this.props.isAuthed) {
|
||||
toast(this.props.t("Please upgrade to Pro to use this feature"));
|
||||
this.props.handleSetting(true);
|
||||
this.props.handleSettingMode("account");
|
||||
@@ -442,6 +448,39 @@ class PopupAssist extends React.Component<PopupAssistProps, PopupAssistState> {
|
||||
toast.success(this.props.t("Copied"));
|
||||
});
|
||||
};
|
||||
handleReportAnswer = async (content: string, index: number) => {
|
||||
const history =
|
||||
this.state.mode === "ask"
|
||||
? this.state.askHistory
|
||||
: this.state.chatHistory;
|
||||
const userMessage = history
|
||||
.slice(0, index)
|
||||
.reverse()
|
||||
.find((item) => item.role === "user");
|
||||
const reason = await vexSelectAsync(
|
||||
"Report reason",
|
||||
REPORT_REASONS.map((item) => ({ value: item, label: item }))
|
||||
);
|
||||
if (!reason) {
|
||||
return;
|
||||
}
|
||||
toast.loading(this.props.t("Please wait"), { id: "report-feedback" });
|
||||
try {
|
||||
await llmReport({
|
||||
answer: content,
|
||||
question: userMessage?.content || "",
|
||||
reason: this.props.t(reason),
|
||||
user_id: (this.props.userInfo?.time_created || "") + "" || "anonymous",
|
||||
});
|
||||
toast.success(this.props.t("Thank you for your feedback"), {
|
||||
id: "report-feedback",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.success(this.props.t("Thank you for your feedback"), {
|
||||
id: "report-feedback",
|
||||
});
|
||||
}
|
||||
};
|
||||
handleRenderHistoryMessage = (message: any[]) => {
|
||||
return message.map((item, index) => {
|
||||
return (
|
||||
@@ -469,6 +508,21 @@ class PopupAssist extends React.Component<PopupAssistProps, PopupAssistState> {
|
||||
<span className="icon-copy-line"></span>
|
||||
</div>
|
||||
)}
|
||||
{item.role === "assistant" && (
|
||||
<div
|
||||
className="popup-assist-copy-button popup-assist-report-button"
|
||||
onClick={() => this.handleReportAnswer(item.content, index)}
|
||||
>
|
||||
<span className="icon-report" style={{ fontWeight: 500 }}></span>
|
||||
</div>
|
||||
)}
|
||||
{item.role === "assistant" && index === message.length - 1 && (
|
||||
<div className="popup-assist-disclaimer">
|
||||
{this.props.t(
|
||||
"AI-generated content is for reference only. Please verify carefully as it does not constitute professional advice."
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -517,6 +571,19 @@ class PopupAssist extends React.Component<PopupAssistProps, PopupAssistState> {
|
||||
this.setState({ chatHistory: [] });
|
||||
}
|
||||
};
|
||||
handleSendQuestion = () => {
|
||||
if (this.state.answer || this.state.isWaiting) {
|
||||
return;
|
||||
}
|
||||
this.handleNewQuestion(this.state.inputQuestion);
|
||||
this.setState({ inputQuestion: "" }, () => {
|
||||
const el = this.textareaRef.current;
|
||||
if (el) {
|
||||
el.style.height = "40px";
|
||||
el.style.overflowY = "hidden";
|
||||
}
|
||||
});
|
||||
};
|
||||
handleNewQuestion = (question: string) => {
|
||||
if (this.state.mode === "ask") {
|
||||
this.setState(
|
||||
@@ -579,8 +646,12 @@ class PopupAssist extends React.Component<PopupAssistProps, PopupAssistState> {
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
justifyContent: this.props.isDockedRight
|
||||
? "space-between"
|
||||
: "flex-start",
|
||||
flexShrink: 0,
|
||||
width: this.props.isDockedRight ? "100%" : undefined,
|
||||
marginTop: this.props.isDockedRight ? "10px" : "0px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -592,6 +663,7 @@ class PopupAssist extends React.Component<PopupAssistProps, PopupAssistState> {
|
||||
onClick={() => {
|
||||
this.setState({ isAddNew: false, mode: "ask" });
|
||||
}}
|
||||
style={{ width: this.props.isDockedRight ? "50%" : undefined }}
|
||||
>
|
||||
<span className={`icon-bookmark trans-icon`}></span>
|
||||
{this.props.t("Reading Assistant")}
|
||||
@@ -605,6 +677,10 @@ class PopupAssist extends React.Component<PopupAssistProps, PopupAssistState> {
|
||||
onClick={() => {
|
||||
this.setState({ isAddNew: false, mode: "chat" });
|
||||
}}
|
||||
style={{
|
||||
width: this.props.isDockedRight ? "50%" : undefined,
|
||||
marginRight: this.props.isDockedRight ? "0px" : "10px",
|
||||
}}
|
||||
>
|
||||
<span className={`icon-idea trans-icon`}></span>
|
||||
{this.props.t("Chat Assistant")}
|
||||
@@ -616,34 +692,46 @@ class PopupAssist extends React.Component<PopupAssistProps, PopupAssistState> {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
width: this.props.isDockedRight ? "100%" : undefined,
|
||||
justifyContent: this.props.isDockedRight
|
||||
? "space-between"
|
||||
: undefined,
|
||||
marginTop: this.props.isDockedRight ? "10px" : "0px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="popup-assist-export-button"
|
||||
style={{ fontSize: 18 }}
|
||||
onClick={this.handleDeleteChatHistory}
|
||||
>
|
||||
<span
|
||||
data-tooltip-id="my-tooltip"
|
||||
data-tooltip-content={this.props.t("Clear chat history")}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<div
|
||||
className="popup-assist-export-button"
|
||||
style={{ fontSize: 18 }}
|
||||
onClick={this.handleDeleteChatHistory}
|
||||
>
|
||||
<span className="icon-trash-line"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="popup-assist-export-button"
|
||||
onClick={this.handleExportChatHistory}
|
||||
>
|
||||
<span
|
||||
data-tooltip-id="my-tooltip"
|
||||
data-tooltip-content={this.props.t("Export chat history")}
|
||||
<span
|
||||
data-tooltip-id="my-tooltip"
|
||||
data-tooltip-content={this.props.t("Clear chat history")}
|
||||
>
|
||||
<span className="icon-trash-line"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="popup-assist-export-button"
|
||||
onClick={this.handleExportChatHistory}
|
||||
>
|
||||
<span className="icon-share"></span>
|
||||
</span>
|
||||
<span
|
||||
data-tooltip-id="my-tooltip"
|
||||
data-tooltip-content={this.props.t("Export chat history")}
|
||||
>
|
||||
<span className="icon-share"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="dict-service-selector"
|
||||
style={{ margin: 0, color: "#f16464" }}
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "#f16464",
|
||||
width: "150px",
|
||||
}}
|
||||
value={this.state.aiService}
|
||||
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
if (event.target.value === "add-new") {
|
||||
@@ -840,6 +928,18 @@ class PopupAssist extends React.Component<PopupAssistProps, PopupAssistState> {
|
||||
marginRight: "10px",
|
||||
marginBottom: "0px",
|
||||
}}
|
||||
onKeyDown={(
|
||||
event: React.KeyboardEvent<HTMLTextAreaElement>
|
||||
) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
!(event.nativeEvent as any).isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.handleSendQuestion();
|
||||
}
|
||||
}}
|
||||
onContextMenu={() => {
|
||||
handleContextMenu("trans-add-content-box");
|
||||
}}
|
||||
@@ -857,19 +957,7 @@ class PopupAssist extends React.Component<PopupAssistProps, PopupAssistState> {
|
||||
/>
|
||||
<div
|
||||
className="popup-assistant-send-button"
|
||||
onClick={() => {
|
||||
if (this.state.answer || this.state.isWaiting) {
|
||||
return;
|
||||
}
|
||||
this.handleNewQuestion(this.state.inputQuestion);
|
||||
this.setState({ inputQuestion: "" }, () => {
|
||||
const el = this.textareaRef.current;
|
||||
if (el) {
|
||||
el.style.height = "40px";
|
||||
el.style.overflowY = "hidden";
|
||||
}
|
||||
});
|
||||
}}
|
||||
onClick={this.handleSendQuestion}
|
||||
>
|
||||
{this.props.t("Send")}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ const mapStateToProps = (state: stateType) => {
|
||||
htmlBook: state.reader.htmlBook,
|
||||
plugins: state.manager.plugins,
|
||||
isAuthed: state.manager.isAuthed,
|
||||
userInfo: state.manager.userInfo,
|
||||
};
|
||||
};
|
||||
const actionCreator = {
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface PopupAssistProps {
|
||||
quoteText: string;
|
||||
plugins: PluginModel[];
|
||||
isAuthed: boolean;
|
||||
userInfo?: any;
|
||||
isDockedRight: boolean;
|
||||
handleQuoteText: (quoteText: string) => void;
|
||||
handleOpenMenu: (isOpenMenu: boolean) => void;
|
||||
|
||||
@@ -51,6 +51,17 @@
|
||||
.popup-assist-copy-button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.popup-assist-report-button {
|
||||
margin-left: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.popup-assist-disclaimer {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
opacity: 0.6;
|
||||
user-select: none;
|
||||
}
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -50,6 +50,7 @@ class PopupDict extends React.Component<PopupDictProps, PopupDictState> {
|
||||
isShowUrl: false,
|
||||
aiAnswer: "",
|
||||
isAiWaiting: false,
|
||||
isAiStreaming: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,7 +163,7 @@ class PopupDict extends React.Component<PopupDictProps, PopupDictState> {
|
||||
systemPrompt = systemPrompt.replace("{to}", targetLang);
|
||||
let config: any = plugin.config || {};
|
||||
this.aiTextAccumulator = "";
|
||||
this.setState({ aiAnswer: "", isAiWaiting: true });
|
||||
this.setState({ aiAnswer: "", isAiWaiting: true, isAiStreaming: true });
|
||||
this.startUpdateInterval();
|
||||
await chatStream(
|
||||
config.endpoint,
|
||||
@@ -185,7 +186,11 @@ class PopupDict extends React.Component<PopupDictProps, PopupDictState> {
|
||||
);
|
||||
this.stopUpdateInterval();
|
||||
this.aiTextAccumulator = "";
|
||||
this.setState({ isAiWaiting: false, dictText: " " });
|
||||
this.setState({
|
||||
isAiWaiting: false,
|
||||
isAiStreaming: false,
|
||||
dictText: " ",
|
||||
});
|
||||
return "";
|
||||
} else if (
|
||||
ConfigService.getReaderConfig("dictService") &&
|
||||
@@ -288,6 +293,7 @@ class PopupDict extends React.Component<PopupDictProps, PopupDictState> {
|
||||
console.error(error);
|
||||
this.setState({
|
||||
dictText: this.props.t("Error happened"),
|
||||
isAiStreaming: false,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
@@ -295,7 +301,7 @@ class PopupDict extends React.Component<PopupDictProps, PopupDictState> {
|
||||
handleDictionaryStream = async (text: string, isFullAnalysis: boolean) => {
|
||||
try {
|
||||
this.aiTextAccumulator = "";
|
||||
this.setState({ aiAnswer: "", isAiWaiting: true });
|
||||
this.setState({ aiAnswer: "", isAiWaiting: true, isAiStreaming: true });
|
||||
this.startUpdateInterval();
|
||||
let res = await getDictionaryStream(
|
||||
text,
|
||||
@@ -315,12 +321,12 @@ class PopupDict extends React.Component<PopupDictProps, PopupDictState> {
|
||||
this.stopUpdateInterval();
|
||||
this.aiTextAccumulator = "";
|
||||
if (res && res.done) {
|
||||
this.setState({ isAiWaiting: false });
|
||||
this.setState({ isAiWaiting: false, isAiStreaming: false });
|
||||
}
|
||||
} catch (error) {
|
||||
this.stopUpdateInterval();
|
||||
this.aiTextAccumulator = "";
|
||||
this.setState({ isAiWaiting: false });
|
||||
this.setState({ isAiWaiting: false, isAiStreaming: false });
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
@@ -537,6 +543,15 @@ class PopupDict extends React.Component<PopupDictProps, PopupDictState> {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!this.state.isAiWaiting &&
|
||||
!this.state.isAiStreaming &&
|
||||
this.state.aiAnswer && (
|
||||
<div className="popup-assist-disclaimer">
|
||||
{this.props.t(
|
||||
"AI-generated content is for reference only. Please verify carefully as it does not constitute professional advice."
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -25,4 +25,5 @@ export interface PopupDictState {
|
||||
isShowUrl: boolean;
|
||||
aiAnswer: string;
|
||||
isAiWaiting: boolean;
|
||||
isAiStreaming: boolean;
|
||||
}
|
||||
@@ -238,3 +238,11 @@
|
||||
line-height: 22px;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.popup-assist-disclaimer {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
opacity: 0.6;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { getIframeDoc } from "../../../utils/reader/docUtil";
|
||||
import { isReadingRawPDF, openExternalUrl } from "../../../utils/common";
|
||||
import Parser from "html-react-parser";
|
||||
import { ConfigService } from "../../../assets/lib/kookit-extra-browser.min";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
class PopupRefer extends React.Component<PopupReferProps, PopupReferStates> {
|
||||
highlighter: any;
|
||||
@@ -179,6 +180,7 @@ class PopupRefer extends React.Component<PopupReferProps, PopupReferStates> {
|
||||
navigator.clipboard.writeText(
|
||||
this.state.footnote.replace(/<[^>]+>/g, "").trim()
|
||||
);
|
||||
toast.success(this.props.t("Copying successful"));
|
||||
}}
|
||||
>
|
||||
{this.props.t("Copy")}
|
||||
|
||||
@@ -32,5 +32,7 @@ export const proFeatures = [
|
||||
"AI reading aids (chapter summaries, outline, multi-role narration)",
|
||||
"Natural text-to-speech (82 languages, 569 voices)",
|
||||
"OCR text recognition for scanned PDFs",
|
||||
"Unlock Pro features on all Platform",
|
||||
"Send to Kindle",
|
||||
"Priority support",
|
||||
];
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
generateSyncRecord,
|
||||
getBookPartialMd5,
|
||||
getTaskStats,
|
||||
getWebsiteLang,
|
||||
getWebsiteUrl,
|
||||
openInBrowser,
|
||||
scanFolderForNewBooks,
|
||||
@@ -670,9 +671,8 @@ class Header extends React.Component<HeaderProps, HeaderState> {
|
||||
let deviceUuid = await TokenService.getFingerprint();
|
||||
let url =
|
||||
getWebsiteUrl() +
|
||||
(ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
? "/zh/faq"
|
||||
: "/en/faq") +
|
||||
getWebsiteLang() +
|
||||
"/faq" +
|
||||
"?referer=app&version=" +
|
||||
packageJson.version +
|
||||
"&client=web&device=" +
|
||||
@@ -754,6 +754,31 @@ class Header extends React.Component<HeaderProps, HeaderState> {
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
{!this.props.isAuthed && (
|
||||
<div
|
||||
className="setting-icon-container"
|
||||
onClick={() => {
|
||||
this.props.handleSetting(true);
|
||||
this.props.handleSettingMode("account");
|
||||
this.props.handleAbout(false);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
this.props.handleAbout(false);
|
||||
}}
|
||||
style={{ marginTop: "2px", fontWeight: "bold" }}
|
||||
>
|
||||
<span
|
||||
data-tooltip-id="my-tooltip"
|
||||
data-tooltip-content={this.props.t("Register / Login")}
|
||||
data-tooltip-place="left"
|
||||
>
|
||||
<span
|
||||
className="icon-account setting-icon"
|
||||
style={{ fontSize: "25px" }}
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="setting-icon-container"
|
||||
onClick={async () => {
|
||||
@@ -866,9 +891,7 @@ class Header extends React.Component<HeaderProps, HeaderState> {
|
||||
let deviceUuid = await TokenService.getFingerprint();
|
||||
openInBrowser(
|
||||
getWebsiteUrl() +
|
||||
(ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
? "/zh"
|
||||
: "/en") +
|
||||
getWebsiteLang() +
|
||||
"/pricing?temp_token=" +
|
||||
tempToken +
|
||||
"&device_uuid=" +
|
||||
@@ -909,9 +932,7 @@ class Header extends React.Component<HeaderProps, HeaderState> {
|
||||
let deviceUuid = await TokenService.getFingerprint();
|
||||
openInBrowser(
|
||||
getWebsiteUrl() +
|
||||
(ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
? "/zh"
|
||||
: "/en") +
|
||||
getWebsiteLang() +
|
||||
"/pricing?temp_token=" +
|
||||
tempToken +
|
||||
"&device_uuid=" +
|
||||
|
||||
@@ -449,7 +449,12 @@ class BookList extends React.Component<BookListProps, BookListState> {
|
||||
onChange={(e) => {
|
||||
this.setState({ readingStatusFilter: e.target.value });
|
||||
}}
|
||||
style={{ marginRight: "10px", width: "70px", borderWidth: "0px" }}
|
||||
style={{
|
||||
marginRight: "10px",
|
||||
width: "70px",
|
||||
borderWidth: "0px",
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
>
|
||||
<option value="" className="lang-setting-option">
|
||||
{this.props.t("All")}
|
||||
|
||||
@@ -312,7 +312,9 @@ class ContentList extends React.Component<ContentListProps, ContentListState> {
|
||||
className="book-content-name content-chapter-title"
|
||||
data-href={item.href}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span className="book-content-title-label" data-href={item.href}>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
style={{ marginRight: "20px", opacity: 0.6, fontSize: "12px" }}
|
||||
>
|
||||
|
||||
@@ -5,7 +5,11 @@ import toast from "react-hot-toast";
|
||||
import { ConfigService } from "../../../assets/lib/kookit-extra-browser.min";
|
||||
import packageJson from "../../../../package.json";
|
||||
|
||||
import { getWebsiteUrl, openExternalUrl } from "../../../utils/common";
|
||||
import {
|
||||
getWebsiteLang,
|
||||
getWebsiteUrl,
|
||||
openExternalUrl,
|
||||
} from "../../../utils/common";
|
||||
import copyTextToClipboard from "copy-text-to-clipboard";
|
||||
import { isElectron } from "react-device-detect";
|
||||
import { checkDeveloperUpdate } from "../../../utils/request/common";
|
||||
@@ -51,19 +55,10 @@ class AboutSetting extends React.Component<SettingInfoProps, SettingInfoState> {
|
||||
id: "checking_update",
|
||||
}
|
||||
);
|
||||
|
||||
let lang = "en";
|
||||
if (
|
||||
ConfigService.getReaderConfig("lang") &&
|
||||
ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
) {
|
||||
lang = "zh";
|
||||
}
|
||||
setTimeout(() => {
|
||||
openExternalUrl(
|
||||
getWebsiteUrl() +
|
||||
"/" +
|
||||
lang +
|
||||
getWebsiteLang() +
|
||||
"/download" +
|
||||
"?version=developer"
|
||||
);
|
||||
@@ -140,14 +135,7 @@ class AboutSetting extends React.Component<SettingInfoProps, SettingInfoState> {
|
||||
<span
|
||||
className="change-location-button"
|
||||
onClick={async () => {
|
||||
if (
|
||||
ConfigService.getReaderConfig("lang") &&
|
||||
ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
) {
|
||||
openExternalUrl(getWebsiteUrl() + "/zh/document");
|
||||
} else {
|
||||
openExternalUrl(getWebsiteUrl() + "/en/document");
|
||||
}
|
||||
openExternalUrl(getWebsiteUrl() + getWebsiteLang() + "/document");
|
||||
}}
|
||||
>
|
||||
<Trans>Visit</Trans>
|
||||
@@ -159,14 +147,7 @@ class AboutSetting extends React.Component<SettingInfoProps, SettingInfoState> {
|
||||
<span
|
||||
className="change-location-button"
|
||||
onClick={async () => {
|
||||
if (
|
||||
ConfigService.getReaderConfig("lang") &&
|
||||
ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
) {
|
||||
openExternalUrl(getWebsiteUrl() + "/zh/faq");
|
||||
} else {
|
||||
openExternalUrl(getWebsiteUrl() + "/en/faq");
|
||||
}
|
||||
openExternalUrl(getWebsiteUrl() + getWebsiteLang() + "/faq");
|
||||
}}
|
||||
>
|
||||
<Trans>Visit</Trans>
|
||||
@@ -178,14 +159,7 @@ class AboutSetting extends React.Component<SettingInfoProps, SettingInfoState> {
|
||||
<span
|
||||
className="change-location-button"
|
||||
onClick={async () => {
|
||||
if (
|
||||
ConfigService.getReaderConfig("lang") &&
|
||||
ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
) {
|
||||
openExternalUrl(getWebsiteUrl() + "/zh/support");
|
||||
} else {
|
||||
openExternalUrl(getWebsiteUrl() + "/en/support");
|
||||
}
|
||||
openExternalUrl(getWebsiteUrl() + getWebsiteLang() + "/support");
|
||||
}}
|
||||
>
|
||||
<Trans>Visit</Trans>
|
||||
@@ -197,14 +171,9 @@ class AboutSetting extends React.Component<SettingInfoProps, SettingInfoState> {
|
||||
<span
|
||||
className="change-location-button"
|
||||
onClick={async () => {
|
||||
if (
|
||||
ConfigService.getReaderConfig("lang") &&
|
||||
ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
) {
|
||||
openExternalUrl(getWebsiteUrl() + "/zh/use-shortcut");
|
||||
} else {
|
||||
openExternalUrl(getWebsiteUrl() + "/en/use-shortcut");
|
||||
}
|
||||
openExternalUrl(
|
||||
getWebsiteUrl() + getWebsiteLang() + "/use-shortcut"
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trans>Visit</Trans>
|
||||
|
||||
@@ -6,6 +6,7 @@ import toast from "react-hot-toast";
|
||||
import {
|
||||
formatTimestamp,
|
||||
getServerRegion,
|
||||
getWebsiteLang,
|
||||
getWebsiteUrl,
|
||||
handleAutoCloudSync,
|
||||
handleContextMenu,
|
||||
@@ -701,8 +702,8 @@ class AccountSetting extends React.Component<
|
||||
<Trans>
|
||||
{this.props.isAuthed ? "Server region" : "Select server region"}
|
||||
</Trans>
|
||||
{ConfigService.getReaderConfig("lang").startsWith("zh") && (
|
||||
<span
|
||||
{ConfigService.getReaderConfig("lang").startsWith("zh") && (
|
||||
<span
|
||||
style={{
|
||||
textDecoration: "underline",
|
||||
marginLeft: "10px",
|
||||
@@ -713,11 +714,7 @@ class AccountSetting extends React.Component<
|
||||
}}
|
||||
onClick={() => {
|
||||
openInBrowser(
|
||||
getWebsiteUrl() +
|
||||
(ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
? "/zh"
|
||||
: "/en") +
|
||||
"/server-region"
|
||||
getWebsiteUrl() + getWebsiteLang() + "/server-region"
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -837,11 +834,7 @@ class AccountSetting extends React.Component<
|
||||
className="account-login-tips"
|
||||
onClick={() => {
|
||||
openInBrowser(
|
||||
getWebsiteUrl() +
|
||||
(ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
? "/zh"
|
||||
: "/en") +
|
||||
"/pricing"
|
||||
getWebsiteUrl() + getWebsiteLang() + "/pricing"
|
||||
);
|
||||
}}
|
||||
style={{
|
||||
@@ -1083,11 +1076,7 @@ class AccountSetting extends React.Component<
|
||||
onClick={async () => {
|
||||
if (!this.props.isAuthed) {
|
||||
openInBrowser(
|
||||
getWebsiteUrl() +
|
||||
(ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
? "/zh"
|
||||
: "/en") +
|
||||
"/pricing"
|
||||
getWebsiteUrl() + getWebsiteLang() + "/pricing"
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1096,11 +1085,7 @@ class AccountSetting extends React.Component<
|
||||
let tempToken = response.data.access_token;
|
||||
let deviceUuid = await TokenService.getFingerprint();
|
||||
openInBrowser(
|
||||
getWebsiteUrl() +
|
||||
(ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
? "/zh"
|
||||
: "/en") +
|
||||
"/pricing?temp_token=" +
|
||||
getWebsiteUrl() + getWebsiteLang() + "/pricing?temp_token=" +
|
||||
tempToken +
|
||||
"&device_uuid=" +
|
||||
deviceUuid
|
||||
|
||||
@@ -98,13 +98,12 @@
|
||||
.background-preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fade-in 0.15s ease;
|
||||
}
|
||||
|
||||
.background-preview-image {
|
||||
|
||||
@@ -19,6 +19,7 @@ class BackgroundSetting extends React.Component<
|
||||
> {
|
||||
fileInputRef = React.createRef<HTMLInputElement>();
|
||||
featuredObserver: IntersectionObserver | null = null;
|
||||
pendingFeaturedElements: HTMLDivElement[] = [];
|
||||
|
||||
constructor(props: SettingInfoProps) {
|
||||
super(props);
|
||||
@@ -49,6 +50,7 @@ class BackgroundSetting extends React.Component<
|
||||
|
||||
setupFeaturedObserver = () => {
|
||||
if (typeof IntersectionObserver === "undefined") return;
|
||||
const root = document.querySelector(".setting-dialog-info");
|
||||
this.featuredObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const newlyVisible: number[] = [];
|
||||
@@ -57,7 +59,8 @@ class BackgroundSetting extends React.Component<
|
||||
const index = Number(
|
||||
(entry.target as HTMLElement).dataset.featuredIndex
|
||||
);
|
||||
if (index && !this.state.visibleFeatured.has(index)) {
|
||||
if (Number.isNaN(index)) return;
|
||||
if (!this.state.visibleFeatured.has(index)) {
|
||||
newlyVisible.push(index);
|
||||
}
|
||||
});
|
||||
@@ -70,8 +73,12 @@ class BackgroundSetting extends React.Component<
|
||||
}));
|
||||
}
|
||||
},
|
||||
{ rootMargin: "300px 0px" }
|
||||
{ root, rootMargin: "120px 0px" }
|
||||
);
|
||||
this.pendingFeaturedElements.forEach((el) =>
|
||||
this.featuredObserver!.observe(el)
|
||||
);
|
||||
this.pendingFeaturedElements = [];
|
||||
};
|
||||
|
||||
registerFeaturedItem = (index: number) => (el: HTMLDivElement | null) => {
|
||||
@@ -79,10 +86,8 @@ class BackgroundSetting extends React.Component<
|
||||
el.dataset.featuredIndex = String(index);
|
||||
if (this.featuredObserver) {
|
||||
this.featuredObserver.observe(el);
|
||||
} else if (!this.state.visibleFeatured.has(index)) {
|
||||
this.setState((prev) => ({
|
||||
visibleFeatured: new Set([...prev.visibleFeatured, index]),
|
||||
}));
|
||||
} else {
|
||||
this.pendingFeaturedElements.push(el);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -240,6 +245,7 @@ class BackgroundSetting extends React.Component<
|
||||
try {
|
||||
const dataUrl = await BackgroundUtil.downloadFeaturedBackground(
|
||||
index,
|
||||
this.props.isAuthed,
|
||||
(progress) => this.setState({ downloadProgress: progress })
|
||||
);
|
||||
if (!dataUrl) {
|
||||
@@ -455,7 +461,10 @@ class BackgroundSetting extends React.Component<
|
||||
{visibleFeatured.has(index) ? (
|
||||
<img
|
||||
className="background-featured-img"
|
||||
src={BackgroundUtil.getFeaturedThumbnailUrl(index)}
|
||||
src={BackgroundUtil.getFeaturedThumbnailUrl(
|
||||
index,
|
||||
this.props.isAuthed
|
||||
)}
|
||||
alt={id}
|
||||
loading="lazy"
|
||||
/>
|
||||
@@ -570,7 +579,10 @@ class BackgroundSetting extends React.Component<
|
||||
/>
|
||||
<img
|
||||
className="background-preview-image"
|
||||
src={BackgroundUtil.getFeaturedOriginalUrl(previewFeatured)}
|
||||
src={BackgroundUtil.getFeaturedOriginalUrl(
|
||||
previewFeatured,
|
||||
this.props.isAuthed
|
||||
)}
|
||||
alt={BackgroundUtil.getFeaturedBackgroundId(previewFeatured)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
@@ -3,9 +3,10 @@ import BackgroundSetting from "./component";
|
||||
import { withTranslation } from "react-i18next";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import { handleReaderBackgroundImage } from "../../../store/actions/reader";
|
||||
import { stateType } from "../../../store";
|
||||
|
||||
const mapStateToProps = () => {
|
||||
return {};
|
||||
const mapStateToProps = (state: stateType) => {
|
||||
return { isAuthed: state.manager.isAuthed };
|
||||
};
|
||||
const actionCreator = { handleReaderBackgroundImage };
|
||||
export default connect(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
|
||||
export interface SettingInfoProps extends RouteComponentProps<any> {
|
||||
isAuthed: boolean;
|
||||
t: (title: string) => string;
|
||||
handleReaderBackgroundImage?: (readerBackgroundImage: string) => void;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
confirmBrowserExtensionAsync,
|
||||
generateSyncRecord,
|
||||
getStorageLocation,
|
||||
getWebsiteLang,
|
||||
getWebsiteUrl,
|
||||
reloadManager,
|
||||
vexComfirmAsync,
|
||||
@@ -141,13 +142,7 @@ class DataSetting extends React.Component<SettingInfoProps, SettingInfoState> {
|
||||
},
|
||||
"",
|
||||
labels,
|
||||
getWebsiteUrl() +
|
||||
`/${
|
||||
ConfigService.getReaderConfig("lang") &&
|
||||
ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
? "zh"
|
||||
: "en"
|
||||
}/add-thirdparty`
|
||||
getWebsiteUrl() + getWebsiteLang() + "/add-thirdparty"
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
@@ -265,11 +260,7 @@ class DataSetting extends React.Component<SettingInfoProps, SettingInfoState> {
|
||||
defaultValues,
|
||||
"",
|
||||
labelsMap,
|
||||
getWebsiteUrl() +
|
||||
(ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
? "/zh"
|
||||
: "/en") +
|
||||
"/add-thirdparty"
|
||||
getWebsiteUrl() + getWebsiteLang() + "/add-thirdparty"
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import _ from "underscore";
|
||||
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
getWebsiteLang,
|
||||
getWebsiteUrl,
|
||||
handleContextMenu,
|
||||
openExternalUrl,
|
||||
@@ -79,10 +80,7 @@ class SettingDialog extends React.Component<
|
||||
this.setState({ availablePlugins: pluginList });
|
||||
};
|
||||
getPluginTutorialUrl = () =>
|
||||
getWebsiteUrl() +
|
||||
(ConfigService.getReaderConfig("lang")?.startsWith("zh")
|
||||
? "/zh/plugin"
|
||||
: "/en/plugin");
|
||||
getWebsiteUrl() + getWebsiteLang() + "/plugin";
|
||||
handleOpenAddNew = async (scrollToTop = false) => {
|
||||
if (window.electronAPI?.runtime?.windowsStore) {
|
||||
return;
|
||||
@@ -329,14 +327,9 @@ class SettingDialog extends React.Component<
|
||||
className="voice-add-cancel"
|
||||
style={{ marginRight: "10px" }}
|
||||
onClick={() => {
|
||||
if (
|
||||
ConfigService.getReaderConfig("lang") &&
|
||||
ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
) {
|
||||
openExternalUrl(getWebsiteUrl() + "/zh/plugin");
|
||||
} else {
|
||||
openExternalUrl(getWebsiteUrl() + "/en/plugin");
|
||||
}
|
||||
openExternalUrl(
|
||||
getWebsiteUrl() + getWebsiteLang() + "/plugin"
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trans>Document</Trans>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
generateSyncRecord,
|
||||
getICloudDrivePath,
|
||||
getServerRegion,
|
||||
getWebsiteLang,
|
||||
getWebsiteUrl,
|
||||
handleContextMenu,
|
||||
openExternalUrl,
|
||||
@@ -964,9 +965,11 @@ class SyncSetting extends React.Component<SettingInfoProps, SettingInfoState> {
|
||||
<div
|
||||
className="voice-add-cancel"
|
||||
style={{ borderWidth: 0, lineHeight: "30px" }}
|
||||
onClick={() => {
|
||||
openExternalUrl(getWebsiteUrl() + "/zh/add-source");
|
||||
}}
|
||||
onClick={() => {
|
||||
openExternalUrl(
|
||||
getWebsiteUrl() + getWebsiteLang() + "/add-source"
|
||||
);
|
||||
}}
|
||||
>
|
||||
{this.props.t("How to fill out")}
|
||||
</div>
|
||||
@@ -1270,10 +1273,7 @@ class SyncSetting extends React.Component<SettingInfoProps, SettingInfoState> {
|
||||
style={{ textDecoration: "underline" }}
|
||||
onClick={() => {
|
||||
openExternalUrl(
|
||||
getWebsiteUrl() +
|
||||
(ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
? "/zh/use-sync"
|
||||
: "/en/use-sync")
|
||||
getWebsiteUrl() + getWebsiteLang() + "/use-sync"
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -15,6 +15,7 @@ import PageWidget from "../pageWidget";
|
||||
import {
|
||||
BRUSH_COLORS,
|
||||
BRUSH_WIDTHS,
|
||||
ERASER_WIDTH_DEFAULT,
|
||||
HIGHLIGHTER_COLORS,
|
||||
HIGHLIGHTER_WIDTHS,
|
||||
SHAPE_TYPES,
|
||||
@@ -391,6 +392,12 @@ class Viewer extends React.Component<ViewerProps, ViewerState> {
|
||||
ConfigService.getReaderConfig("annotationShapeWidth") ||
|
||||
BRUSH_WIDTHS[1] + ""
|
||||
),
|
||||
eraserWidth: parseFloat(
|
||||
ConfigService.getReaderConfig("annotationEraserWidth") ||
|
||||
ERASER_WIDTH_DEFAULT + ""
|
||||
),
|
||||
quickErase: ConfigService.getReaderConfig("annotationQuickErase") ||
|
||||
"no",
|
||||
textSize: parseFloat(
|
||||
ConfigService.getReaderConfig("annotationTextSize") || "24"
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { loginList } from "../../constants/loginList";
|
||||
import {
|
||||
generateSyncRecord,
|
||||
getServerRegion,
|
||||
getWebsiteLang,
|
||||
getWebsiteUrl,
|
||||
handleAutoCloudSync,
|
||||
handleContextMenu,
|
||||
@@ -407,7 +408,7 @@ class Login extends React.Component<LoginProps, LoginState> {
|
||||
})}
|
||||
<div className="login-billing-info">
|
||||
{this.props.t(
|
||||
"7-day free trial upon registration, then billed annually"
|
||||
"7-day free trial upon registration, then billed $4.99 per Year"
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
@@ -420,13 +421,7 @@ class Login extends React.Component<LoginProps, LoginState> {
|
||||
}}
|
||||
onClick={() => {
|
||||
openInBrowser(
|
||||
getWebsiteUrl() +
|
||||
(ConfigService.getReaderConfig("lang").startsWith(
|
||||
"zh"
|
||||
)
|
||||
? "/zh"
|
||||
: "/en") +
|
||||
"/pricing"
|
||||
getWebsiteUrl() + getWebsiteLang() + "/pricing"
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -497,7 +492,10 @@ class Login extends React.Component<LoginProps, LoginState> {
|
||||
}
|
||||
})
|
||||
.filter((item) => {
|
||||
if (isElectron && window.electronAPI?.runtime?.platform !== "darwin") {
|
||||
if (
|
||||
isElectron &&
|
||||
window.electronAPI?.runtime?.platform !== "darwin"
|
||||
) {
|
||||
return item.value !== "icloud";
|
||||
}
|
||||
return true;
|
||||
|
||||
+46
-12
@@ -189,11 +189,7 @@ export const confirmBrowserExtensionAsync = async (): Promise<boolean> => {
|
||||
"Install extension"
|
||||
);
|
||||
if (!result) {
|
||||
const lang = ConfigService.getReaderConfig("lang");
|
||||
openExternalUrl(
|
||||
getWebsiteUrl() +
|
||||
(lang?.startsWith("zh") ? "/zh/use-extension" : "/en/use-extension")
|
||||
);
|
||||
openExternalUrl(getWebsiteUrl() + getWebsiteLang() + "/use-extension");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -378,6 +374,14 @@ export const vexPasswordInputAsync = (
|
||||
});
|
||||
};
|
||||
|
||||
export const REPORT_REASONS = [
|
||||
"Inaccurate or misleading information",
|
||||
"Inappropriate or offensive content",
|
||||
"Irrelevant or off-topic response",
|
||||
"Privacy concern",
|
||||
"Other",
|
||||
];
|
||||
|
||||
export const vexSelectAsync = (
|
||||
message: string,
|
||||
options: { value: string; label: string }[]
|
||||
@@ -498,7 +502,9 @@ export function throttle<T extends (...args: any[]) => void>(
|
||||
export const scrollContents = (chapterTitle: string, chapterHref: string) => {
|
||||
let contentBody = document.getElementsByClassName("navigation-body")[0];
|
||||
if (!contentBody) return;
|
||||
let contentList = contentBody.getElementsByClassName("book-content-name");
|
||||
let contentList = contentBody.getElementsByClassName(
|
||||
"book-content-title-label"
|
||||
);
|
||||
let targetContent = Array.from(contentList).filter((item) => {
|
||||
item.setAttribute("style", "");
|
||||
let dataHref = (item as any).getAttribute("data-href");
|
||||
@@ -898,8 +904,19 @@ export const getDefaultTransTarget = (langList) => {
|
||||
);
|
||||
return langMap[langTarget || "English"];
|
||||
};
|
||||
export const WEBSITE_URL = "https://koodoreader.com";
|
||||
export const CN_WEBSITE_URL = "https://koodoreader.cn";
|
||||
export const WEBSITE_URL = "https://koodoreader.com/";
|
||||
export const CN_WEBSITE_URL = "https://koodoreader.cn/";
|
||||
export const WEBSITE_LANGS = [
|
||||
"zh",
|
||||
"en",
|
||||
"ja",
|
||||
"ko",
|
||||
"fr",
|
||||
"de",
|
||||
"es",
|
||||
"pt",
|
||||
"ru",
|
||||
];
|
||||
export const getServerRegion = () => {
|
||||
let isUseCN = false;
|
||||
if (ConfigService.getItem("serverRegion")) {
|
||||
@@ -918,6 +935,10 @@ export const getServerRegion = () => {
|
||||
export const getWebsiteUrl = () => {
|
||||
return getServerRegion() === "china" ? CN_WEBSITE_URL : WEBSITE_URL;
|
||||
};
|
||||
export const getWebsiteLang = () => {
|
||||
const lang = ConfigService.getReaderConfig("lang") || "en";
|
||||
return WEBSITE_LANGS.find((code) => lang.startsWith(code)) || "en";
|
||||
};
|
||||
export const formatTimestamp = (timestamp) => {
|
||||
if (!timestamp) return "";
|
||||
|
||||
@@ -1565,10 +1586,15 @@ export const splitSentences = (text: string, maxLength?: number) => {
|
||||
if (sentence.length <= resolvedMaxLength) return [sentence];
|
||||
|
||||
// Try splitting by common punctuation marks (Chinese and Western)
|
||||
const parts = sentence
|
||||
.split(/(?<=[,,;;::、…])/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
// Avoid lookbehind (?<=) which is unsupported on low-version iOS Safari
|
||||
const rawParts = sentence.split(/([,,;;::、…])/);
|
||||
const parts = [] as string[];
|
||||
for (let i = 0; i < rawParts.length; i += 2) {
|
||||
const merged = (rawParts[i] + (rawParts[i + 1] ?? "")).trim();
|
||||
if (merged.length > 0) {
|
||||
parts.push(merged);
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length > 1) {
|
||||
// Greedily merge parts to minimize the number of resulting chunks
|
||||
@@ -1709,6 +1735,10 @@ export const prepareThirdConfig = async (service: string, config: any) => {
|
||||
// Get access token
|
||||
let refreshToken = config.refresh_token;
|
||||
let res = await refreshThirdToken(service, refreshToken);
|
||||
//网络问题不移除数据源
|
||||
if (res && res.code === 503) {
|
||||
return {};
|
||||
}
|
||||
if (!res.data || !res.data.access_token) {
|
||||
toast.error(
|
||||
i18n.t(
|
||||
@@ -1910,6 +1940,10 @@ export const BRUSH_COLORS = [
|
||||
];
|
||||
|
||||
export const BRUSH_WIDTHS = [2, 4, 8, 14];
|
||||
export const ERASER_WIDTH_MIN = 6;
|
||||
export const ERASER_WIDTH_MAX = 60;
|
||||
export const ERASER_WIDTH_STEP = 2;
|
||||
export const ERASER_WIDTH_DEFAULT = 24;
|
||||
export const HIGHLIGHTER_COLORS = [
|
||||
"#FFE54C",
|
||||
"#6FFB6B",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isElectron } from "react-device-detect";
|
||||
import { getStorageLocation } from "../common";
|
||||
import { getServerRegion, getStorageLocation } from "../common";
|
||||
import { ConfigService } from "../../assets/lib/kookit-extra-browser.min";
|
||||
import { LocalFileManager } from "./localFile";
|
||||
import localforage from "localforage";
|
||||
@@ -208,12 +208,20 @@ class BackgroundUtil {
|
||||
return `official-background-${index}`;
|
||||
}
|
||||
|
||||
static getFeaturedThumbnailUrl(index: number): string {
|
||||
return `https://storage.koodoreader.com/backgrounds/desktop-thumbnail/official-background-${index}.png`;
|
||||
static getFeaturedThumbnailUrl(index: number, isAuthed: boolean): string {
|
||||
const base =
|
||||
getServerRegion() === "china" && isAuthed
|
||||
? "https://storage.koodoreader.cn"
|
||||
: "https://storage.koodoreader.com";
|
||||
return `${base}/backgrounds/desktop-thumbnail/official-background-${index}.png`;
|
||||
}
|
||||
|
||||
static getFeaturedOriginalUrl(index: number): string {
|
||||
return `https://storage.koodoreader.com/backgrounds/desktop/official-background-${index}.png`;
|
||||
static getFeaturedOriginalUrl(index: number, isAuthed: boolean): string {
|
||||
const base =
|
||||
getServerRegion() === "china" && isAuthed
|
||||
? "https://storage.koodoreader.cn"
|
||||
: "https://storage.koodoreader.com";
|
||||
return `${base}/backgrounds/desktop/official-background-${index}.png`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,11 +230,12 @@ class BackgroundUtil {
|
||||
*/
|
||||
static async downloadFeaturedBackground(
|
||||
index: number,
|
||||
isAuthed: boolean,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<string | null> {
|
||||
const id = this.getFeaturedBackgroundId(index);
|
||||
const extension = "png";
|
||||
const response = await fetch(this.getFeaturedOriginalUrl(index), {
|
||||
const response = await fetch(this.getFeaturedOriginalUrl(index, isAuthed), {
|
||||
headers: {
|
||||
"Cache-Control": "no-transform",
|
||||
"Accept-Encoding": "identity",
|
||||
@@ -265,9 +274,8 @@ class BackgroundUtil {
|
||||
await this.saveImageBuffer(id, arrayBuffer, extension);
|
||||
const base64 = Buffer.from(arrayBuffer).toString("base64");
|
||||
const dataUrl = `data:image/${extension};base64,${base64}`;
|
||||
const { backgroundColor, textColor } = await this.analyzeImageColors(
|
||||
dataUrl
|
||||
);
|
||||
const { backgroundColor, textColor } =
|
||||
await this.analyzeImageColors(dataUrl);
|
||||
this.saveImageMeta(id, {
|
||||
name: `${i18n.t("Official background")} ${index}`,
|
||||
extension,
|
||||
|
||||
@@ -72,7 +72,12 @@ export const getSelectionSentence = (
|
||||
let fullText = (el as Element)?.textContent || "";
|
||||
let selectedText = sel.toString().trim();
|
||||
// Split on sentence-ending punctuation to find the sentence
|
||||
let sentences = fullText.split(/(?<=[.!?。!?])\s*/);
|
||||
// Avoid lookbehind (?<=) which is unsupported on low-version iOS Safari
|
||||
const rawParts = fullText.split(/([.!?。!?])\s*/);
|
||||
const sentences: string[] = [];
|
||||
for (let i = 0; i < rawParts.length; i += 2) {
|
||||
sentences.push(rawParts[i] + (rawParts[i + 1] ?? ""));
|
||||
}
|
||||
for (let s of sentences) {
|
||||
if (s.includes(selectedText)) {
|
||||
return s.trim();
|
||||
@@ -407,12 +412,21 @@ export const bindHtmlEvent = (
|
||||
renderBookFunc();
|
||||
return;
|
||||
}
|
||||
// 阅读尺等辅助模式下滚轮完全接管为逐段导航,preventDefault 必须在
|
||||
// lock 检查之前执行:锁定期内到达的连续滚轮事件若不拦截默认行为,
|
||||
// 会触发原生滚动与遮罩过渡动画叠加,造成动画掉帧(键盘事件无默认
|
||||
// 滚动行为,所以键盘快捷键不存在此问题)
|
||||
if (
|
||||
readerMode === "scroll" &&
|
||||
Math.abs(event.deltaX) === 0 &&
|
||||
isReadingAidMode(format, key)
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (lock) return;
|
||||
lock = true;
|
||||
if (readerMode === "scroll") {
|
||||
if (Math.abs(event.deltaX) === 0 && isReadingAidMode(format, key)) {
|
||||
// 段落模式下阻止原生滚动导致遮罩漂移,改为逐段导航
|
||||
event.preventDefault();
|
||||
await mouseChrome(rendition, event.deltaY);
|
||||
} else {
|
||||
await sleep(200);
|
||||
|
||||
@@ -249,6 +249,18 @@ export const getNotification = async () => {
|
||||
// }
|
||||
return res;
|
||||
};
|
||||
export const llmReport = async (params: {
|
||||
answer: string;
|
||||
question: string;
|
||||
reason: string;
|
||||
user_id: string;
|
||||
}) => {
|
||||
const res = await axios.post(
|
||||
"https://api.koodoreader.com/api/llm_report",
|
||||
params
|
||||
);
|
||||
return res;
|
||||
};
|
||||
export const parseWithSystemOCR = async (imageBase64: string) => {
|
||||
if (!isElectron) {
|
||||
return;
|
||||
|
||||
@@ -7,6 +7,7 @@ import i18n from "../../i18n";
|
||||
import { handleExitApp } from "./common";
|
||||
import {
|
||||
getServerRegion,
|
||||
getWebsiteLang,
|
||||
getWebsiteUrl,
|
||||
openExternalUrl,
|
||||
openInBrowser,
|
||||
@@ -222,11 +223,7 @@ export const getTTSAudio = async (
|
||||
isShowingQuotaAlert = false;
|
||||
quotaAlertDismissTime = Date.now();
|
||||
openExternalUrl(
|
||||
getWebsiteUrl() +
|
||||
(ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
? "/zh"
|
||||
: "/en") +
|
||||
"/tts-quota"
|
||||
getWebsiteUrl() + getWebsiteLang() + "/tts-quota"
|
||||
);
|
||||
} else {
|
||||
isShowingQuotaAlert = false;
|
||||
@@ -253,11 +250,7 @@ export const getTTSAudio = async (
|
||||
let tempToken = response.data.access_token;
|
||||
let deviceUuid = await TokenService.getFingerprint();
|
||||
openInBrowser(
|
||||
getWebsiteUrl() +
|
||||
(ConfigService.getReaderConfig("lang").startsWith("zh")
|
||||
? "/zh"
|
||||
: "/en") +
|
||||
"/pricing?temp_token=" +
|
||||
getWebsiteUrl() + getWebsiteLang() + "/pricing?temp_token=" +
|
||||
tempToken +
|
||||
"&device_uuid=" +
|
||||
deviceUuid
|
||||
|
||||
Reference in new issue
Block a user