fr1: books.ruzzy.dev — читалка с ИИ (PDF/EPUB → главы, рекапы, перевод/объяснение выделенного, вопросы с RAG; Claude Code по подписке без инструментов; вход через Telegram)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8fd84239be
commit
9aab5b3f40
@@ -0,0 +1,142 @@
|
||||
// Книги: выделение → перевод/объяснение/вопрос, потоковые ответы ИИ, рекапы, отметки прочитанного.
|
||||
"use strict";
|
||||
|
||||
const esc = s => s.replace(/[&<>"']/g, c => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[c]));
|
||||
|
||||
// Мини-Markdown: сначала экранируем всё, потом размечаем — HTML из ответов ИИ не исполняется.
|
||||
function md(src) {
|
||||
const lines = esc(src || "").split("\n");
|
||||
const out = [];
|
||||
let list = null, code = false;
|
||||
const inline = t => t
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
|
||||
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<i>$2</i>")
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||||
const close = () => { if (list) { out.push(`</${list}>`); list = null; } };
|
||||
for (const l of lines) {
|
||||
if (l.startsWith("```")) { close(); out.push(code ? "</pre>" : "<pre>"); code = !code; continue; }
|
||||
if (code) { out.push(l); continue; }
|
||||
let m;
|
||||
if ((m = l.match(/^(#{1,4})\s+(.*)/))) { close(); const n = Math.min(4, m[1].length + 1); out.push(`<h${n}>${inline(m[2])}</h${n}>`); }
|
||||
else if ((m = l.match(/^\s*[-*•]\s+(.*)/))) { if (list !== "ul") { close(); out.push("<ul>"); list = "ul"; } out.push(`<li>${inline(m[1])}</li>`); }
|
||||
else if ((m = l.match(/^\s*\d+[.)]\s+(.*)/))) { if (list !== "ol") { close(); out.push("<ol>"); list = "ol"; } out.push(`<li>${inline(m[1])}</li>`); }
|
||||
else if (!l.trim()) { close(); }
|
||||
else { close(); out.push(`<p>${inline(l)}</p>`); }
|
||||
}
|
||||
close(); if (code) out.push("</pre>");
|
||||
return out.join("\n");
|
||||
}
|
||||
document.querySelectorAll(".md").forEach(el => { el.innerHTML = md(el.textContent); });
|
||||
|
||||
// Потоковый запрос: текст ответа приходит кусками.
|
||||
async function streamTo(url, body, el, method = "POST") {
|
||||
el.innerHTML = '<p class="muted">Думаю…</p>';
|
||||
let acc = "";
|
||||
try {
|
||||
const r = await fetch(url, {method, headers: {"Content-Type": "application/json"}, body: body ? JSON.stringify(body) : null});
|
||||
if (!r.ok) { el.innerHTML = `<p class="err">Ошибка ${r.status}: ${esc(await r.text())}</p>`; return ""; }
|
||||
const rd = r.body.getReader(), dec = new TextDecoder();
|
||||
for (;;) {
|
||||
const {done, value} = await rd.read();
|
||||
if (done) break;
|
||||
acc += dec.decode(value, {stream: true});
|
||||
el.innerHTML = md(acc);
|
||||
}
|
||||
} catch (e) { el.innerHTML = `<p class="err">Сеть: ${esc(String(e))}</p>`; }
|
||||
return acc;
|
||||
}
|
||||
|
||||
// ---- вопросы (по книге и по главе)
|
||||
document.querySelectorAll("form.ask").forEach(f => f.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const q = f.q.value.trim(); if (!q) return;
|
||||
const btn = f.querySelector("button"); btn.disabled = true;
|
||||
await streamTo("/api/ask", {q, book: f.dataset.book, ch: f.dataset.ch || null, web: f.web.checked},
|
||||
f.parentElement.querySelector(".answer"));
|
||||
btn.disabled = false;
|
||||
}));
|
||||
|
||||
// ---- выделение в тексте главы
|
||||
const text = document.getElementById("text");
|
||||
const bar = document.getElementById("selbar"), panel = document.getElementById("panel");
|
||||
let picked = "";
|
||||
if (text && bar) {
|
||||
const show = () => {
|
||||
const s = window.getSelection();
|
||||
const t = s && s.toString().trim();
|
||||
if (!t || t.length < 2 || !text.contains(s.anchorNode)) { bar.hidden = true; return; }
|
||||
picked = t;
|
||||
const rect = s.getRangeAt(0).getBoundingClientRect();
|
||||
bar.style.top = (window.scrollY + rect.bottom + 8) + "px";
|
||||
bar.style.left = Math.max(8, Math.min(window.innerWidth - 300, rect.left)) + "px";
|
||||
bar.hidden = false;
|
||||
};
|
||||
document.addEventListener("selectionchange", () => { clearTimeout(show.t); show.t = setTimeout(show, 350); });
|
||||
const run = (act, extra) => {
|
||||
panel.hidden = false; bar.hidden = true;
|
||||
const body = document.getElementById("panel-body");
|
||||
const ask = document.getElementById("panel-ask");
|
||||
document.getElementById("panel-title").textContent = {translate: "Перевод", explain: "Объяснение", ask: "Вопрос о выделенном"}[act];
|
||||
if (act === "ask") { ask.hidden = false; body.innerHTML = `<blockquote>${esc(picked.slice(0, 400))}</blockquote>`; ask.querySelector("textarea").focus(); return; }
|
||||
ask.hidden = true;
|
||||
streamTo(`/api/selection/${act}`, {book: text.dataset.book, ch: text.dataset.ch, text: picked}, body);
|
||||
};
|
||||
bar.addEventListener("mousedown", e => e.preventDefault()); // не сбрасывать выделение кликом
|
||||
bar.querySelectorAll("button").forEach(b => b.addEventListener("click", () => run(b.dataset.act)));
|
||||
document.getElementById("panel-close").onclick = () => { panel.hidden = true; };
|
||||
document.getElementById("panel-ask-go").onclick = () => {
|
||||
const q = document.querySelector("#panel-ask textarea").value.trim(); if (!q) return;
|
||||
streamTo("/api/ask", {q, book: text.dataset.book, ch: text.dataset.ch, selection: picked,
|
||||
web: document.getElementById("panel-web").checked}, document.getElementById("panel-body"));
|
||||
};
|
||||
}
|
||||
|
||||
// ---- рекап главы
|
||||
const gen = document.getElementById("gen-recap");
|
||||
if (gen) gen.onclick = async () => {
|
||||
gen.disabled = true;
|
||||
const view = document.getElementById("recap-view");
|
||||
const t = await streamTo(`/api/recap/${gen.dataset.book}/${gen.dataset.ch}`, null, view);
|
||||
if (t && !t.startsWith("⚠️")) location.reload(); else gen.disabled = false;
|
||||
};
|
||||
const save = document.getElementById("save-recap");
|
||||
if (save) save.onclick = async () => {
|
||||
const val = document.getElementById("recap-edit").value;
|
||||
const r = await fetch(`/api/recap/${save.dataset.book}/${save.dataset.ch}`, {method: "PUT", headers: {"Content-Type": "application/json"}, body: JSON.stringify({md: val})});
|
||||
if (r.ok) { document.getElementById("recap-view").innerHTML = md(val); save.textContent = "Сохранено ✓"; setTimeout(() => save.textContent = "Сохранить", 1500); }
|
||||
};
|
||||
const useAi = document.getElementById("use-ai");
|
||||
if (useAi) useAi.onclick = e => { e.preventDefault(); document.getElementById("recap-edit").value = document.getElementById("ai-md").value; };
|
||||
|
||||
// ---- прочитано
|
||||
const read = document.getElementById("read");
|
||||
if (read) read.onchange = () => fetch(`/api/read/${read.dataset.book}/${read.dataset.ch}`, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({read: read.checked})});
|
||||
|
||||
// ---- рекапы выбранных глав по очереди
|
||||
const many = document.getElementById("recap-selected");
|
||||
if (many) many.onclick = async () => {
|
||||
const only = document.getElementById("only-missing").checked;
|
||||
const picks = [...document.querySelectorAll(".pick:checked")].filter(c => !(only && c.dataset.has));
|
||||
if (!picks.length) { alert("Отметь главы галочками"); return; }
|
||||
many.disabled = true;
|
||||
for (const c of picks) {
|
||||
const st = c.closest("li").querySelector(".state");
|
||||
st.textContent = "⏳ рекап…";
|
||||
const r = await fetch(`/api/recap/${many.dataset.book}/${c.value}`, {method: "POST"});
|
||||
const t = r.ok ? await r.text() : "";
|
||||
st.textContent = r.ok && !t.startsWith("⚠️") ? "📝 готово" : "⚠️ не вышло";
|
||||
if (r.ok) { c.dataset.has = "1"; c.checked = false; }
|
||||
}
|
||||
many.disabled = false;
|
||||
};
|
||||
|
||||
// ---- книга ещё обрабатывается — опрос статуса
|
||||
const busy = document.getElementById("busy");
|
||||
if (busy) {
|
||||
const poll = async () => {
|
||||
const r = await fetch(`/api/status/${busy.dataset.book}`); const s = await r.json();
|
||||
if (s.status === "ready" || s.status === "error") location.reload(); else setTimeout(poll, 3000);
|
||||
};
|
||||
setTimeout(poll, 3000);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/* Простая вёрстка, удобная и для e-ink (Boox): чёрное на белом, без анимаций и полупрозрачности. */
|
||||
:root { --fg: #111; --bg: #fff; --muted: #666; --line: #ccc; --card: #fafafa; --accent: #000; --warn: #fff6d6; --err: #b00020; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --fg: #e8e8e8; --bg: #141414; --muted: #9a9a9a; --line: #333; --card: #1c1c1c; --accent: #fff; --warn: #3a3218; --err: #ff6b6b; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body { margin: 0; background: var(--bg); color: var(--fg); font: 17px/1.55 -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
|
||||
a { color: var(--accent); }
|
||||
main { max-width: 760px; margin: 0 auto; padding: 12px 16px 80px; }
|
||||
h1 { font-size: 1.5em; line-height: 1.25; margin: .4em 0; }
|
||||
h2 { font-size: 1.15em; margin: .2em 0 .5em; }
|
||||
.muted { color: var(--muted); } .small { font-size: .85em; }
|
||||
.err { color: var(--err); } .warn { background: var(--warn); padding: 8px 10px; border-radius: 6px; }
|
||||
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .between { justify-content: space-between; }
|
||||
.card { border: 1px solid var(--line); background: var(--card); border-radius: 8px; padding: 12px 14px; margin: 16px 0; }
|
||||
input, textarea, button { font: inherit; color: var(--fg); background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 7px 10px; }
|
||||
textarea { width: 100%; }
|
||||
input[type=checkbox] { width: 1.1em; height: 1.1em; vertical-align: -2px; }
|
||||
button { cursor: pointer; border-color: var(--fg); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
button.danger { border-color: var(--err); color: var(--err); }
|
||||
.top { display: flex; gap: 12px; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--bg); z-index: 5; }
|
||||
.brand { font-weight: 700; text-decoration: none; white-space: nowrap; }
|
||||
.topsearch { flex: 1; } .topsearch input { width: 100%; }
|
||||
.books, .hits { list-style: none; padding: 0; } .books li, .hits li { padding: 10px 0; border-bottom: 1px solid var(--line); }
|
||||
.btitle { font-weight: 600; text-decoration: none; }
|
||||
.toc { padding-left: 1.4em; } .toc li { margin: 4px 0; } .toc .lvl2 { margin-left: 1.2em; font-size: .95em; }
|
||||
.reader { font-size: 1.08em; line-height: 1.7; hyphens: auto; -webkit-hyphens: auto; }
|
||||
.reader p { margin: 0 0 .8em; text-align: left; }
|
||||
.pg { color: var(--muted); font-size: .75em; text-align: center; margin: 1.2em 0 .6em; }
|
||||
.md p { margin: .4em 0; } .md ul, .md ol { padding-left: 1.3em; margin: .4em 0; }
|
||||
.md pre, code { background: var(--card); border: 1px solid var(--line); border-radius: 4px; padding: 0 3px; font-size: .9em; }
|
||||
.md pre { padding: 8px; overflow-x: auto; }
|
||||
blockquote { border-left: 3px solid var(--line); margin: .5em 0; padding-left: 10px; color: var(--muted); }
|
||||
.qa { border-top: 1px solid var(--line); padding: 8px 0; }
|
||||
#selbar { position: absolute; z-index: 20; display: flex; gap: 6px; background: var(--bg); border: 1px solid var(--fg); border-radius: 8px; padding: 6px; }
|
||||
#panel { position: fixed; left: 0; right: 0; bottom: 0; max-height: 55vh; overflow-y: auto; background: var(--bg); border-top: 2px solid var(--fg); padding: 10px 16px 18px; z-index: 30; }
|
||||
#panel > * { max-width: 760px; margin-left: auto; margin-right: auto; }
|
||||
.center { display: flex; min-height: 100vh; align-items: center; justify-content: center; }
|
||||
.login { text-align: center; }
|
||||
@media (max-width: 520px) { body { font-size: 16px; } .top { padding: 8px 10px; } main { padding: 10px 12px 80px; } }
|
||||
Reference in New Issue
Block a user