sagadeck 1.3.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/bin/sagadeck.js +9 -1
- package/docs/ANDAMENTO.md +376 -0
- package/docs/REFERENCIA.md +4 -4
- package/package.json +1 -1
- package/src/ai/bench.js +132 -0
- package/src/ai/context.js +12 -3
- package/src/ai/critique.js +114 -0
- package/src/ai/deck-ai.js +234 -30
- package/src/ai/document-materials.js +2 -2
- package/src/ai/document-visuals.js +400 -20
- package/src/ai/llm.js +19 -5
- package/src/ai/progress.js +23 -0
- package/src/ai/quality.js +60 -0
- package/src/api-client.js +2 -1
- package/src/build.js +5 -1
- package/src/decision-lab.js +2 -1
- package/src/deck-file.js +2 -1
- package/src/elements.js +44 -7
- package/src/figures/charts.js +7 -4
- package/src/figures/diagrams.js +15 -1
- package/src/figures/ufmap.js +2 -1
- package/src/fs-retry.js +19 -0
- package/src/import/crop.js +27 -8
- package/src/layouts.js +22 -2
- package/src/library.js +68 -16
- package/src/locale.js +18 -0
- package/src/mcp/server.js +2 -2
- package/src/motion.js +4 -2
- package/src/preferences.js +2 -1
- package/src/runtime/base.css +3 -0
- package/src/runtime/calc.js +3 -3
- package/src/runtime/decision-lab.js +1 -1
- package/src/runtime/fit.js +20 -1
- package/src/runtime/runtime.js +2 -1
- package/src/studio/ai-chat-route.js +119 -4
- package/src/studio/code-version.js +35 -0
- package/src/studio/generate.js +53 -0
- package/src/studio/project.js +8 -7
- package/src/studio/public/app.js +7 -216
- package/src/studio/public/chat.js +71 -1
- package/src/studio/public/header-footer.js +140 -0
- package/src/studio/public/index.html +5 -0
- package/src/studio/public/library.html +1 -0
- package/src/studio/public/library.js +4 -3
- package/src/studio/public/preferences-ui.js +83 -0
- package/src/studio/public/provenance.js +13 -0
- package/src/studio/public/style.css +17 -0
- package/src/studio/public/ui-icons.js +1 -1
- package/src/studio/public/update-notice.js +25 -0
- package/src/studio/public-files.js +11 -1
- package/src/studio/server.js +20 -29
- package/src/studio/share-routes.js +2 -1
package/src/studio/project.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Protegida: só some junto com o projeto inteiro. Nada disso vai no pacote .sagadeck
|
|
7
7
|
// (src/package.js leva só o que o deck usa).
|
|
8
8
|
import fs from "node:fs";
|
|
9
|
+
import { renameRetry } from "../fs-retry.js";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import crypto from "node:crypto";
|
|
11
12
|
import JSZip from "jszip";
|
|
@@ -82,7 +83,7 @@ export function ensureMeta(P) {
|
|
|
82
83
|
fs.mkdirSync(path.join(P.dir, CONTEXT), { recursive: true }); // à vista: é aqui que moram anexos, prints e planilhas
|
|
83
84
|
const conv = path.join(P.dir, META, "conversa.json"), legacy = P.file.replace(/\.ya?ml$/i, ".conversa.json");
|
|
84
85
|
// conversa antiga (ao lado do deck) passa para dentro do projeto
|
|
85
|
-
if (fs.existsSync(legacy) && !fs.existsSync(conv)) { try {
|
|
86
|
+
if (fs.existsSync(legacy) && !fs.existsSync(conv)) { try { renameRetry(legacy, conv); } catch {} }
|
|
86
87
|
return conv;
|
|
87
88
|
}
|
|
88
89
|
export const conversationFile = (P) => ensureMeta(P);
|
|
@@ -101,7 +102,7 @@ export function writeText(P, rel, text) {
|
|
|
101
102
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
102
103
|
const tmp = `${abs}.tmp-${process.pid}`;
|
|
103
104
|
fs.writeFileSync(tmp, String(text ?? ""), "utf8");
|
|
104
|
-
|
|
105
|
+
renameRetry(tmp, abs);
|
|
105
106
|
return { path: r };
|
|
106
107
|
}
|
|
107
108
|
export function createFile(P, dirRel, name, text = "") {
|
|
@@ -136,7 +137,7 @@ export function rename(P, fromRel, toName) {
|
|
|
136
137
|
const to = resolveIn(P, path.posix.join(path.posix.dirname(r), n));
|
|
137
138
|
if (fs.existsSync(to)) throw new Error(`Já existe "${n}" nessa pasta.`);
|
|
138
139
|
if (isMeta(relOf(P, to))) throw new Error(`A pasta ${META} é do Studio.`);
|
|
139
|
-
|
|
140
|
+
renameRetry(abs, to);
|
|
140
141
|
return { path: relOf(P, to) };
|
|
141
142
|
}
|
|
142
143
|
export function move(P, fromRel, toDirRel) {
|
|
@@ -146,7 +147,7 @@ export function move(P, fromRel, toDirRel) {
|
|
|
146
147
|
if (isMeta(d)) throw new Error(`A pasta ${META} é do Studio.`);
|
|
147
148
|
if (dest === abs || dest.startsWith(abs + path.sep)) throw new Error("Não dá para mover uma pasta para dentro dela mesma.");
|
|
148
149
|
const to = unique(path.join(dest, path.basename(abs)));
|
|
149
|
-
|
|
150
|
+
renameRetry(abs, to);
|
|
150
151
|
return { path: relOf(P, to) };
|
|
151
152
|
}
|
|
152
153
|
// apagar = mandar para a lixeira do projeto (.sagadeck/lixeira), de onde dá para restaurar
|
|
@@ -158,7 +159,7 @@ export function remove(P, rel) {
|
|
|
158
159
|
fs.mkdirSync(bin, { recursive: true });
|
|
159
160
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
160
161
|
const to = path.join(bin, `${stamp}__${r.replace(/\//g, "__")}`);
|
|
161
|
-
|
|
162
|
+
renameRetry(abs, to);
|
|
162
163
|
return { trashed: relOf(P, to) };
|
|
163
164
|
}
|
|
164
165
|
export function restore(P, trashedRel) {
|
|
@@ -167,7 +168,7 @@ export function restore(P, trashedRel) {
|
|
|
167
168
|
const orig = path.basename(abs).split("__").slice(1).join("/");
|
|
168
169
|
const to = unique(resolveIn(P, orig));
|
|
169
170
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
170
|
-
|
|
171
|
+
renameRetry(abs, to);
|
|
171
172
|
return { path: relOf(P, to) };
|
|
172
173
|
}
|
|
173
174
|
|
|
@@ -228,7 +229,7 @@ export function writeSheet(P, rel, rows) {
|
|
|
228
229
|
const clean = rows.map((r) => r.map((c) => String(c ?? "")));
|
|
229
230
|
while (clean.length > 1 && clean.at(-1).every((c) => c === "")) clean.pop(); // linha vazia no fim (o cursor passou dela) não vai para o arquivo
|
|
230
231
|
fs.writeFileSync(tmp, toCSV(clean, old));
|
|
231
|
-
|
|
232
|
+
renameRetry(tmp, abs);
|
|
232
233
|
return { path: relOf(P, abs), delimiter: old.delimiter };
|
|
233
234
|
}
|
|
234
235
|
|
package/src/studio/public/app.js
CHANGED
|
@@ -1907,6 +1907,8 @@
|
|
|
1907
1907
|
rv.title = slide.review.note || "";
|
|
1908
1908
|
card.appendChild(rv);
|
|
1909
1909
|
}
|
|
1910
|
+
const pv = window.SagaProvenance?.(slide, hydrateIcons); // origem do conteúdo (provenance.js)
|
|
1911
|
+
if (pv) card.appendChild(pv);
|
|
1910
1912
|
if (Array.isArray(slide.auto) && slide.auto.length) {
|
|
1911
1913
|
const badge = document.createElement("span");
|
|
1912
1914
|
badge.className = "thumb-auto";
|
|
@@ -3343,142 +3345,8 @@
|
|
|
3343
3345
|
}
|
|
3344
3346
|
}
|
|
3345
3347
|
|
|
3346
|
-
//
|
|
3347
|
-
|
|
3348
|
-
// --------------------------------------------------------------------------
|
|
3349
|
-
const HF_PRESETS = [
|
|
3350
|
-
{ id: "padrao", name: "Padrão", desc: "Título e número", footer: null, header: null },
|
|
3351
|
-
{ id: "autor", name: "Autor e evento", desc: "Autor · evento e 3 / 20", footer: { left: "{autor} · {evento}", right: "{n} / {total}" }, header: null },
|
|
3352
|
-
{ id: "evento", name: "Evento e data", desc: "Evento, data e número", footer: { left: "{evento}", center: "{data:DD MMM AAAA}", right: "{pagina}" }, header: null },
|
|
3353
|
-
{ id: "corp", name: "Corporativo", desc: "Área e Confidencial em cima; título, data e página embaixo",
|
|
3354
|
-
header: { left: "{depto}", right: "Confidencial" }, footer: { left: "{titulo}", center: "{data:MM/AAAA}", right: "{n} / {total}" } },
|
|
3355
|
-
{ id: "numero", name: "Só o número", desc: "Número discreto no canto", footer: { right: "{n}" }, header: null },
|
|
3356
|
-
{ id: "nenhum", name: "Nenhum", desc: "Slides limpos", footer: false, header: null },
|
|
3357
|
-
];
|
|
3358
|
-
const HF_TOKENS = [["{titulo}", "Título"], ["{autor}", "Autor"], ["{evento}", "Evento"], ["{depto}", "Departamento"], ["{data:DD/MM/AAAA}", "Data"],
|
|
3359
|
-
["{data:DD MMM AAAA}", "Data por extenso"], ["{pagina}", "Página (01)"], ["{n}", "Página (1)"], ["{total}", "Total"]];
|
|
3360
|
-
const HF_SLOTS = ["header.left", "header.center", "header.right", "footer.left", "footer.center", "footer.right"];
|
|
3361
|
-
const hfModal = () => document.getElementById("modal-hf");
|
|
3362
|
-
let hfLastInput = null;
|
|
3363
|
-
let hfTimer = null;
|
|
3364
|
-
|
|
3365
|
-
// footer/header do deck -> valores dos 6 campos (o padrão sem footer = título + número)
|
|
3366
|
-
function hfSlotsFrom(v, kind, deck) {
|
|
3367
|
-
if (v === false) return { left: "", center: "", right: "" };
|
|
3368
|
-
if (v == null) return kind === "footer" ? { left: "{titulo}", center: "", right: "{pagina}" } : { left: "", center: "", right: "" };
|
|
3369
|
-
if (typeof v === "string") return { left: v, center: "", right: "{pagina}" };
|
|
3370
|
-
return { left: v.left || "", center: v.center || "", right: v.right || "" };
|
|
3371
|
-
}
|
|
3372
|
-
|
|
3373
|
-
function hfFill(deckLike) {
|
|
3374
|
-
const m = hfModal();
|
|
3375
|
-
for (const k of ["author", "event", "department", "date"]) m.querySelector(`[data-deck="${k}"]`).value = deckLike[k] || "";
|
|
3376
|
-
for (const kind of ["header", "footer"]) {
|
|
3377
|
-
const v = hfSlotsFrom(deckLike[kind], kind, deckLike);
|
|
3378
|
-
for (const pos of ["left", "center", "right"]) m.querySelector(`[data-slot="${kind}.${pos}"]`).value = v[pos];
|
|
3379
|
-
}
|
|
3380
|
-
}
|
|
3381
|
-
|
|
3382
|
-
// o que os campos do modal significam como deck (footer/header "limpos": null = padrão, false = nenhum)
|
|
3383
|
-
function hfRead() {
|
|
3384
|
-
const m = hfModal();
|
|
3385
|
-
const out = {};
|
|
3386
|
-
for (const k of ["author", "event", "department", "date"]) out[k] = m.querySelector(`[data-deck="${k}"]`).value.trim();
|
|
3387
|
-
for (const kind of ["header", "footer"]) {
|
|
3388
|
-
const v = Object.fromEntries(["left", "center", "right"].map((p) => [p, m.querySelector(`[data-slot="${kind}.${p}"]`).value.trim()]));
|
|
3389
|
-
const empty = !v.left && !v.center && !v.right;
|
|
3390
|
-
if (kind === "footer" && v.left === "{titulo}" && !v.center && v.right === "{pagina}") out.footer = null;
|
|
3391
|
-
else if (empty) out[kind] = kind === "footer" ? false : null;
|
|
3392
|
-
else out[kind] = Object.fromEntries(Object.entries(v).filter(([, x]) => x));
|
|
3393
|
-
}
|
|
3394
|
-
return out;
|
|
3395
|
-
}
|
|
3396
|
-
|
|
3397
|
-
function hfApplyTo(deck, vals) {
|
|
3398
|
-
for (const [k, v] of Object.entries(vals)) {
|
|
3399
|
-
if (v == null || v === "") delete deck[k];
|
|
3400
|
-
else deck[k] = v;
|
|
3401
|
-
}
|
|
3402
|
-
return deck;
|
|
3403
|
-
}
|
|
3404
|
-
|
|
3405
|
-
function openHeaderFooter() {
|
|
3406
|
-
const m = hfModal();
|
|
3407
|
-
const presets = m.querySelector(".hf-presets");
|
|
3408
|
-
if (!presets.childElementCount) {
|
|
3409
|
-
for (const p of HF_PRESETS) {
|
|
3410
|
-
const b = document.createElement("button");
|
|
3411
|
-
b.type = "button";
|
|
3412
|
-
b.className = "hf-preset";
|
|
3413
|
-
b.dataset.preset = p.id;
|
|
3414
|
-
b.innerHTML = `<b></b><span></span>`;
|
|
3415
|
-
b.querySelector("b").textContent = p.name;
|
|
3416
|
-
b.querySelector("span").textContent = p.desc;
|
|
3417
|
-
b.onclick = () => {
|
|
3418
|
-
const cur = hfRead();
|
|
3419
|
-
hfFill({ ...cur, footer: p.footer, header: p.header });
|
|
3420
|
-
presets.querySelectorAll(".hf-preset").forEach((x) => x.classList.toggle("active", x === b));
|
|
3421
|
-
hfPreview();
|
|
3422
|
-
};
|
|
3423
|
-
presets.append(b);
|
|
3424
|
-
}
|
|
3425
|
-
const chips = m.querySelector(".hf-tokens");
|
|
3426
|
-
for (const [tok, label] of HF_TOKENS) {
|
|
3427
|
-
const c = document.createElement("button");
|
|
3428
|
-
c.type = "button";
|
|
3429
|
-
c.className = "hf-token";
|
|
3430
|
-
c.dataset.token = tok;
|
|
3431
|
-
c.textContent = label;
|
|
3432
|
-
c.title = tok;
|
|
3433
|
-
c.onmousedown = (e) => e.preventDefault(); // não rouba o foco do campo
|
|
3434
|
-
c.onclick = () => {
|
|
3435
|
-
const inp = hfLastInput || m.querySelector('[data-slot="footer.left"]');
|
|
3436
|
-
const a = inp.selectionStart ?? inp.value.length, z = inp.selectionEnd ?? a;
|
|
3437
|
-
inp.value = inp.value.slice(0, a) + tok + inp.value.slice(z);
|
|
3438
|
-
inp.focus();
|
|
3439
|
-
inp.setSelectionRange(a + tok.length, a + tok.length);
|
|
3440
|
-
hfPreview();
|
|
3441
|
-
};
|
|
3442
|
-
chips.append(c);
|
|
3443
|
-
}
|
|
3444
|
-
m.querySelectorAll("input").forEach((inp) => {
|
|
3445
|
-
inp.addEventListener("focus", () => { if (inp.dataset.slot) hfLastInput = inp; });
|
|
3446
|
-
inp.addEventListener("input", () => { clearTimeout(hfTimer); hfTimer = setTimeout(hfPreview, 250); });
|
|
3447
|
-
});
|
|
3448
|
-
}
|
|
3449
|
-
hfFill(state.deck);
|
|
3450
|
-
m.classList.remove("hidden");
|
|
3451
|
-
hfPreview();
|
|
3452
|
-
}
|
|
3453
|
-
|
|
3454
|
-
function closeHeaderFooter() {
|
|
3455
|
-
hfModal().classList.add("hidden");
|
|
3456
|
-
}
|
|
3457
|
-
|
|
3458
|
-
async function hfPreview() {
|
|
3459
|
-
const stage = hfModal().querySelector(".hf-stage");
|
|
3460
|
-
const spec = hfApplyTo(JSON.parse(JSON.stringify(state.deck)), hfRead());
|
|
3461
|
-
// na prévia, um slide que mostra rodapé (a capa não mostra)
|
|
3462
|
-
let i = state.currentSlideIndex;
|
|
3463
|
-
const NO_BARS = ["cover", "section", "end", "image", "canvas", "full", "headline"];
|
|
3464
|
-
if (NO_BARS.includes(spec.slides[i]?.layout)) i = Math.max(0, spec.slides.findIndex((s) => !NO_BARS.includes(s.layout)));
|
|
3465
|
-
try {
|
|
3466
|
-
const r = await (await fetch("api/render-slide", { method: "POST", headers: { "Content-Type": "application/json" },
|
|
3467
|
-
body: JSON.stringify({ slide: spec.slides[i], index: i, spec }) })).json();
|
|
3468
|
-
stage.innerHTML = `<div class="thumb-render">${r.html}</div>`;
|
|
3469
|
-
requestAnimationFrame(() => fitRendered(stage));
|
|
3470
|
-
stage.style.setProperty("--thumb-scale", String(stage.clientWidth / 1920));
|
|
3471
|
-
} catch {}
|
|
3472
|
-
}
|
|
3473
|
-
|
|
3474
|
-
function applyHeaderFooter() {
|
|
3475
|
-
hfApplyTo(state.deck, hfRead());
|
|
3476
|
-
closeHeaderFooter();
|
|
3477
|
-
syncDeckToServer();
|
|
3478
|
-
renderCurrentSlide();
|
|
3479
|
-
renderThumbnails();
|
|
3480
|
-
showToast("Cabeçalho e rodapé aplicados em todos os slides", 2200);
|
|
3481
|
-
}
|
|
3348
|
+
// Cabeçalho e rodapé: modelos prontos, campos com variáveis e prévia (header-footer.js)
|
|
3349
|
+
const { openHeaderFooter, closeHeaderFooter, applyHeaderFooter } = window.SagaHeaderFooter({ state, fitRendered, syncDeckToServer, renderCurrentSlide, renderThumbnails, showToast });
|
|
3482
3350
|
|
|
3483
3351
|
// ---------------------------------------------------------------- slides de API
|
|
3484
3352
|
// Ambientes: o ~/.sagadeck/ambientes.yaml como texto, validado pelo Studio antes de gravar
|
|
@@ -3758,86 +3626,8 @@ ${ta.value}`;
|
|
|
3758
3626
|
document.querySelectorAll(".split-button.show, .file-menu-wrap.show").forEach((m) => { if (!m.contains(except)) m.classList.remove("show"); });
|
|
3759
3627
|
}
|
|
3760
3628
|
|
|
3761
|
-
//
|
|
3762
|
-
|
|
3763
|
-
// as do editor, neste navegador. Cada linha diz o que faz; gravar é automático.
|
|
3764
|
-
// ==========================================================================
|
|
3765
|
-
const PREFS_UI = [
|
|
3766
|
-
{ id: "texto", title: "Texto e código", items: [
|
|
3767
|
-
{ k: "texto.minCodePt", type: "num", unit: "pt", min: 6, max: 24, label: "Tamanho mínimo do código ao encolher", hint: "Código que não cabe encolhe até aqui (10 pt ≈ 20 px no slide). Se nem assim couber, ele rola na apresentação e o fiscal avisa." },
|
|
3768
|
-
{ k: "texto.minTextPt", type: "num", unit: "pt", min: 6, max: 30, label: "Tamanho mínimo do texto ao encolher", hint: "Títulos e textos que se ajustam para caber não ficam menores que isto." },
|
|
3769
|
-
{ k: "texto.wrapCode", type: "bool", label: "Quebrar linhas longas de código", hint: "Em vez de encolher o bloco todo por causa de uma linha comprida." },
|
|
3770
|
-
] },
|
|
3771
|
-
{ id: "ia", title: "Inteligência artificial", items: [
|
|
3772
|
-
{ k: "ia.pesquisa", type: "bool", label: "Pesquisar na web quando precisar", hint: "Ranking, lançamentos, preços, um artigo científico: a IA pesquisa, lê as fontes confiáveis e cita cada uma. Desligado (ou sem internet), ela usa só o que sabe e avisa." },
|
|
3773
|
-
{ k: "ia.perguntar", type: "bool", label: "Perguntar quando o pedido não disser para que serve o material", hint: "Ex.: um workshop sem dizer se o pessoal vai guardar o material. Desligado, a IA decide sozinha." },
|
|
3774
|
-
{ k: "ia.imagens", type: "bool", label: "Gerar imagens quando o pedido pedir", hint: "Desligado: só ícones, gráficos e diagramas do sagadeck (sem custo de imagem)." },
|
|
3775
|
-
{ k: "ia.autor", type: "text", label: "Seu nome", hint: "Vai na capa e no rodapé das apresentações novas (no lugar de \"Seu Nome\")." },
|
|
3776
|
-
{ k: "ia.idioma", type: "select", options: [["auto", "O do pedido"], ["português do Brasil", "Português (Brasil)"], ["inglês", "Inglês"], ["espanhol", "Espanhol"]], label: "Idioma do conteúdo gerado" },
|
|
3777
|
-
] },
|
|
3778
|
-
{ id: "exportacao", title: "Exportação", items: [
|
|
3779
|
-
{ k: "exportacao.pdfClaro", type: "bool", label: "PDF na versão clara do tema", hint: "Tema escuro com par claro (ex.: Manual noite) sai claro no PDF, melhor para imprimir." },
|
|
3780
|
-
] },
|
|
3781
|
-
{ id: "editor", title: "Editor (este navegador)", local: true, items: [
|
|
3782
|
-
{ k: "editor.theme", type: "select", options: [["system", "Automática (do sistema)"], ["light", "Clara"], ["dark", "Escura"]], label: "Tema da interface", hint: "Só a interface do Studio; o slide mantém o tema dele.",
|
|
3783
|
-
get: () => store.get("appTheme", "system"), set: (v) => setAppTheme(v) },
|
|
3784
|
-
{ k: "editor.guides", type: "bool", label: "Mostrar as guias da área segura", hint: "O retângulo pontilhado onde o conteúdo cabe sem cortar.",
|
|
3785
|
-
get: () => dom.chkGuides.checked, set: (v) => { dom.chkGuides.checked = v; dom.chkGuides.dispatchEvent(new Event("change", { bubbles: true })); } },
|
|
3786
|
-
{ k: "editor.inspect", type: "bool", label: "Marcar no slide os avisos do fiscal", hint: "Caixas em volta do texto fora da margem, código que não coube, sobreposição.",
|
|
3787
|
-
get: () => dom.chkInspectOverlay.checked, set: (v) => { dom.chkInspectOverlay.checked = v; dom.chkInspectOverlay.dispatchEvent(new Event("change", { bubbles: true })); } },
|
|
3788
|
-
] },
|
|
3789
|
-
];
|
|
3790
|
-
let prefsData = null, prefsTimer = null;
|
|
3791
|
-
const prefGet = (key) => { const [sec, k] = key.split("."); return prefsData?.[sec]?.[k]; };
|
|
3792
|
-
async function openPrefs() {
|
|
3793
|
-
const modal = document.getElementById("modal-prefs");
|
|
3794
|
-
modal.classList.remove("hidden");
|
|
3795
|
-
try {
|
|
3796
|
-
const r = await fetch("api/preferences"); const j = await r.json();
|
|
3797
|
-
prefsData = j.prefs; document.getElementById("prefs-file").textContent = j.file || "";
|
|
3798
|
-
} catch { prefsData = {}; }
|
|
3799
|
-
renderPrefs();
|
|
3800
|
-
const q = document.getElementById("prefs-search"); q.value = ""; q.focus();
|
|
3801
|
-
}
|
|
3802
|
-
function renderPrefs() {
|
|
3803
|
-
const nav = document.getElementById("prefs-nav"), list = document.getElementById("prefs-list");
|
|
3804
|
-
nav.innerHTML = PREFS_UI.map((sec) => `<button type="button" data-prefs-sec="${sec.id}">${escHtml(sec.title)}</button>`).join("");
|
|
3805
|
-
list.innerHTML = PREFS_UI.map((sec) => `<section class="prefs-sec" data-prefs-sec="${sec.id}"><h4>${escHtml(sec.title)}</h4>${sec.items.map((it) => {
|
|
3806
|
-
const v = it.get ? it.get() : prefGet(it.k);
|
|
3807
|
-
const ctl = it.type === "bool" ? `<input type="checkbox" data-pref="${it.k}"${v ? " checked" : ""} aria-label="${escAttr(it.label)}">`
|
|
3808
|
-
: it.type === "select" ? `<select class="form-control" data-pref="${it.k}" aria-label="${escAttr(it.label)}">${it.options.map(([ov, ol]) => `<option value="${ov}"${String(v) === ov ? " selected" : ""}>${escHtml(ol)}</option>`).join("")}</select>`
|
|
3809
|
-
: it.type === "num" ? `<span class="pref-num"><input type="number" class="form-control" data-pref="${it.k}" value="${escAttr(v ?? "")}" min="${it.min}" max="${it.max}" step="1" aria-label="${escAttr(it.label)}"><span>${it.unit || ""}</span></span>`
|
|
3810
|
-
: `<input type="text" class="form-control" data-pref="${it.k}" value="${escAttr(v ?? "")}" aria-label="${escAttr(it.label)}">`;
|
|
3811
|
-
return `<div class="pref-row" data-search="${escAttr(fold(`${sec.title} ${it.label} ${it.hint || ""}`))}"><div class="pref-label"><b>${escHtml(it.label)}</b>${it.hint ? `<small>${escHtml(it.hint)}</small>` : ""}</div><div class="pref-ctl">${ctl}</div></div>`;
|
|
3812
|
-
}).join("")}</section>`).join("");
|
|
3813
|
-
nav.querySelectorAll("[data-prefs-sec]").forEach((b) => b.onclick = () => list.querySelector(`section[data-prefs-sec="${b.dataset.prefsSec}"]`)?.scrollIntoView({ block: "start" }));
|
|
3814
|
-
list.querySelectorAll("[data-pref]").forEach((el) => el.addEventListener("change", () => changePref(el)));
|
|
3815
|
-
}
|
|
3816
|
-
function changePref(el) {
|
|
3817
|
-
const key = el.dataset.pref, it = PREFS_UI.flatMap((s) => s.items).find((x) => x.k === key);
|
|
3818
|
-
const value = el.type === "checkbox" ? el.checked : it.type === "num" ? Number(el.value) : el.value;
|
|
3819
|
-
const status = document.getElementById("prefs-status");
|
|
3820
|
-
if (it.set) { it.set(value); status.textContent = "Salvo neste navegador"; return; }
|
|
3821
|
-
const [sec, k] = key.split(".");
|
|
3822
|
-
(prefsData[sec] ||= {})[k] = value;
|
|
3823
|
-
clearTimeout(prefsTimer);
|
|
3824
|
-
status.textContent = "Salvando…";
|
|
3825
|
-
prefsTimer = setTimeout(async () => {
|
|
3826
|
-
try {
|
|
3827
|
-
const r = await fetch("api/preferences", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prefs: { [sec]: prefsData[sec] } }) });
|
|
3828
|
-
const j = await r.json(); if (!r.ok) throw new Error(j.error || r.status);
|
|
3829
|
-
prefsData = j.prefs;
|
|
3830
|
-
const shown = document.querySelector(`#prefs-list [data-pref="${key}"]`); if (shown && shown.type === "number") shown.value = prefGet(key); // voltou para a faixa aceita
|
|
3831
|
-
status.textContent = "Salvo";
|
|
3832
|
-
renderCurrentSlide(); renderThumbnails(); // os mínimos do ajuste para caber mudam o desenho
|
|
3833
|
-
} catch (e) { status.textContent = "Não salvou: " + e.message; }
|
|
3834
|
-
}, 250);
|
|
3835
|
-
}
|
|
3836
|
-
function filterPrefs() {
|
|
3837
|
-
const q = fold(document.getElementById("prefs-search").value.trim());
|
|
3838
|
-
document.querySelectorAll("#prefs-list .pref-row").forEach((r) => { r.hidden = !!q && !r.dataset.search.includes(q); });
|
|
3839
|
-
document.querySelectorAll("#prefs-list .prefs-sec").forEach((sec) => { sec.hidden = !sec.querySelector(".pref-row:not([hidden])"); });
|
|
3840
|
-
}
|
|
3629
|
+
// Preferências: tela única com busca (preferences-ui.js)
|
|
3630
|
+
const { openPrefs, filterPrefs } = window.SagaPrefs({ dom, store, escHtml, escAttr, fold, setAppTheme, renderCurrentSlide, renderThumbnails });
|
|
3841
3631
|
|
|
3842
3632
|
function syncMotionMenu() {
|
|
3843
3633
|
const cur = state.deck?.motion || "subtle";
|
|
@@ -4300,6 +4090,7 @@ ${ta.value}`;
|
|
|
4300
4090
|
if (files.length) { e.preventDefault(); files.forEach(addChatImage); }
|
|
4301
4091
|
});
|
|
4302
4092
|
dom.chatAttach.onclick = () => dom.chatAttachInput.click();
|
|
4093
|
+
document.getElementById("chat-critique").onclick = () => chatApi.runCritique();
|
|
4303
4094
|
dom.chatAttachInput.onchange = () => { [...dom.chatAttachInput.files].forEach(addChatFile); dom.chatAttachInput.value = ""; };
|
|
4304
4095
|
|
|
4305
4096
|
// Chips de Sugestões de Prompt do Chat
|
|
@@ -451,9 +451,79 @@ window.SagaChat = function ({ state, dom, escHtml, hydrateIcons, showToast, open
|
|
|
451
451
|
dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
|
|
452
452
|
return msgDiv;
|
|
453
453
|
}
|
|
454
|
+
// Leitura crítica do material anexado (botão do chat): o servidor lê o documento como um revisor da área e devolve
|
|
455
|
+
// inconsistências, achados escondidos, perguntas prováveis e slides que representam mal o material, cada item com
|
|
456
|
+
// trecho conferido. Nada muda nos slides; vira uma mensagem da conversa com opções para aplicar.
|
|
457
|
+
async function runCritique() {
|
|
458
|
+
if (chatJob) return;
|
|
459
|
+
appendChatMessage("user", "Leitura crítica do material");
|
|
460
|
+
const job = chatJob = { file: state.file, id: crypto.randomUUID(), controller: new AbortController() };
|
|
461
|
+
job.stop = () => job.controller.abort();
|
|
462
|
+
const work = createProgressBubble("Lendo o material com olho crítico…");
|
|
463
|
+
try {
|
|
464
|
+
const data = await streamAI("api/ai/critique", { spec: state.deck, requestId: job.id }, (ev) => work.update(ev), job.controller.signal);
|
|
465
|
+
state.chatHistory.push({ role: "user", text: "Leitura crítica do material" });
|
|
466
|
+
state.chatHistory.push({ role: "assistant", text: data.reply + (data.options?.length ? `\n(opções: ${data.options.join(" | ")})` : ""), talk: true });
|
|
467
|
+
await saveChatHistory();
|
|
468
|
+
work.done();
|
|
469
|
+
// com os pontos estruturados, a bolha traz o resumo e cada ponto vira um cartão (o texto inteiro fica no histórico)
|
|
470
|
+
const msg = appendChatMessage("ai", data.critique?.itens?.length ? data.critique.summary : data.reply, data.actions || []);
|
|
471
|
+
msg.classList.add("bs");
|
|
472
|
+
if (data.critique?.itens?.length) renderCritiqueItems(msg, data.critique);
|
|
473
|
+
const tag = document.createElement("div");
|
|
474
|
+
tag.className = "bs-tag";
|
|
475
|
+
tag.innerHTML = '<i class="ic" data-ic="scan-search"></i> Leitura crítica — nada mudou nos slides'; hydrateIcons(tag);
|
|
476
|
+
msg.querySelector(".ai-content").prepend(tag);
|
|
477
|
+
renderChatOptions(msg, data.options);
|
|
478
|
+
updateBrainstormApply();
|
|
479
|
+
} catch (e) {
|
|
480
|
+
work.done();
|
|
481
|
+
appendChatMessage("ai", `A leitura crítica não saiu: ${e.message}`);
|
|
482
|
+
} finally { chatJob = null; }
|
|
483
|
+
}
|
|
484
|
+
// Cada ponto da leitura crítica como cartão: o slide a que ele se liga (clique leva até lá), Aplicar (pede à IA só
|
|
485
|
+
// aquele ponto, mirando aquele slide; a autoria decide se vai ao slide ou às notes) e Ignorar (gravado: o ponto deixa
|
|
486
|
+
// de acompanhar os pedidos).
|
|
487
|
+
const CRIT_LABEL = { destaque: "Achado", inconsistencia: "Inconsistência", slide: "Slide a rever", pergunta: "Pergunta provável", limitacao: "Limitação", forte: "Ponto forte" };
|
|
488
|
+
function goToSlide(n) {
|
|
489
|
+
if (!(n >= 1 && n <= state.deck.slides.length)) return;
|
|
490
|
+
state.currentSlideIndex = n - 1;
|
|
491
|
+
renderThumbnails(); renderCurrentSlide();
|
|
492
|
+
}
|
|
493
|
+
async function markCritique(id, status) {
|
|
494
|
+
try { await fetch("api/ai/critique/item", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, status }) }); } catch { /* fica só na tela */ }
|
|
495
|
+
}
|
|
496
|
+
function renderCritiqueItems(msg, critique) {
|
|
497
|
+
const list = document.createElement("div");
|
|
498
|
+
list.className = "crit-list";
|
|
499
|
+
for (const it of critique.itens) {
|
|
500
|
+
const card = document.createElement("div");
|
|
501
|
+
card.className = `crit-item crit-${it.tipo}`;
|
|
502
|
+
card.dataset.id = it.id;
|
|
503
|
+
card.innerHTML = `<div class="crit-head"><span class="crit-kind"></span>${it.slide ? `<button type="button" class="crit-slide" title="Ir ao slide ${it.slide}">Slide ${it.slide}</button>` : ""}</div><b class="crit-title"></b><div class="crit-text"></div>${it.trecho ? '<blockquote class="crit-quote"></blockquote>' : ""}<div class="crit-actions"><button type="button" class="bs-option crit-apply"><i class="ic" data-ic="check"></i> Aplicar${it.slide ? ` no slide ${it.slide}` : ""}</button><button type="button" class="bs-option crit-ignore"><i class="ic" data-ic="x"></i> Ignorar</button></div>`;
|
|
504
|
+
card.querySelector(".crit-kind").textContent = CRIT_LABEL[it.tipo] || it.tipo;
|
|
505
|
+
card.querySelector(".crit-title").textContent = it.titulo;
|
|
506
|
+
card.querySelector(".crit-text").textContent = `${it.texto}${it.onde ? ` (${it.onde})` : ""}`;
|
|
507
|
+
if (it.trecho) card.querySelector(".crit-quote").textContent = `"${it.trecho}"`;
|
|
508
|
+
const done = (label) => { card.classList.add("crit-done"); card.querySelector(".crit-actions").innerHTML = `<span class="crit-status">${label}</span>`; };
|
|
509
|
+
card.querySelector(".crit-slide")?.addEventListener("click", () => goToSlide(it.slide));
|
|
510
|
+
card.querySelector(".crit-apply").onclick = () => {
|
|
511
|
+
if (chatJob) { showToast("Espere a IA terminar o pedido atual.", 3000); return; }
|
|
512
|
+
goToSlide(it.slide);
|
|
513
|
+
done(it.slide ? `Aplicado no slide ${it.slide}` : "Aplicado");
|
|
514
|
+
markCritique(it.id, "aplicado");
|
|
515
|
+
dom.chatInput.value = `Aplique este ponto da leitura crítica${it.slide ? ` no slide ${it.slide}` : ", no slide em que ele se encaixa"}: ${it.titulo}. ${it.texto}${it.trecho ? ` (trecho do material: "${it.trecho}")` : ""}. Siga a autoria da apresentação: achado do material pode ir para o slide; crítica, inconsistência e pergunta vão para as notes ("Prepare-se: …"), salvo se a apresentação for livre.`;
|
|
516
|
+
handleChatSubmit();
|
|
517
|
+
};
|
|
518
|
+
card.querySelector(".crit-ignore").onclick = () => { done("Ignorado"); markCritique(it.id, "ignorado"); };
|
|
519
|
+
list.append(card);
|
|
520
|
+
}
|
|
521
|
+
hydrateIcons(list);
|
|
522
|
+
msg.querySelector(".ai-content").append(list);
|
|
523
|
+
}
|
|
454
524
|
// Bolha de progresso do chat e de outros fluxos (transformação usa a mesma).
|
|
455
525
|
const createProgressBubble = window.SagaChatProgress({ dom, appendChatMessage, hydrateIcons, getChatJob });
|
|
456
|
-
const api = { getChatJob, handleChatSubmit, loadChatHistory, saveChatHistory, appendChatMessage,
|
|
526
|
+
const api = { getChatJob, handleChatSubmit, runCritique, loadChatHistory, saveChatHistory, appendChatMessage,
|
|
457
527
|
createProgressBubble, commandEvent, updateBrainstormApply, renderVariants, applyBrainstorm,
|
|
458
528
|
renderChatOptions, clearChatAttachments, renderChatAttachments, addChatImage, addChatDoc,
|
|
459
529
|
addChatFile, autoGrowChat, commandCard };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Cabeçalho e rodapé do deck: modelos prontos, campos com variáveis ({autor}, {data:…}, {n}) e prévia do slide atual.
|
|
2
|
+
// Saiu do app.js pela trava de tamanho dos monolitos; o comportamento é o mesmo.
|
|
3
|
+
// Fábrica no molde de slide-select.js: recebe o estado e os ajudantes, devolve a API.
|
|
4
|
+
window.SagaHeaderFooter = function ({ state, fitRendered, syncDeckToServer, renderCurrentSlide, renderThumbnails, showToast }) {
|
|
5
|
+
const HF_PRESETS = [
|
|
6
|
+
{ id: "padrao", name: "Padrão", desc: "Título e número", footer: null, header: null },
|
|
7
|
+
{ id: "autor", name: "Autor e evento", desc: "Autor · evento e 3 / 20", footer: { left: "{autor} · {evento}", right: "{n} / {total}" }, header: null },
|
|
8
|
+
{ id: "evento", name: "Evento e data", desc: "Evento, data e número", footer: { left: "{evento}", center: "{data:DD MMM AAAA}", right: "{pagina}" }, header: null },
|
|
9
|
+
{ id: "corp", name: "Corporativo", desc: "Área e Confidencial em cima; título, data e página embaixo",
|
|
10
|
+
header: { left: "{depto}", right: "Confidencial" }, footer: { left: "{titulo}", center: "{data:MM/AAAA}", right: "{n} / {total}" } },
|
|
11
|
+
{ id: "numero", name: "Só o número", desc: "Número discreto no canto", footer: { right: "{n}" }, header: null },
|
|
12
|
+
{ id: "nenhum", name: "Nenhum", desc: "Slides limpos", footer: false, header: null },
|
|
13
|
+
];
|
|
14
|
+
const HF_TOKENS = [["{titulo}", "Título"], ["{autor}", "Autor"], ["{evento}", "Evento"], ["{depto}", "Departamento"], ["{data:DD/MM/AAAA}", "Data"],
|
|
15
|
+
["{data:DD MMM AAAA}", "Data por extenso"], ["{pagina}", "Página (01)"], ["{n}", "Página (1)"], ["{total}", "Total"]];
|
|
16
|
+
const HF_SLOTS = ["header.left", "header.center", "header.right", "footer.left", "footer.center", "footer.right"];
|
|
17
|
+
const hfModal = () => document.getElementById("modal-hf");
|
|
18
|
+
let hfLastInput = null;
|
|
19
|
+
let hfTimer = null;
|
|
20
|
+
|
|
21
|
+
// footer/header do deck -> valores dos 6 campos (o padrão sem footer = título + número)
|
|
22
|
+
function hfSlotsFrom(v, kind, deck) {
|
|
23
|
+
if (v === false) return { left: "", center: "", right: "" };
|
|
24
|
+
if (v == null) return kind === "footer" ? { left: "{titulo}", center: "", right: "{pagina}" } : { left: "", center: "", right: "" };
|
|
25
|
+
if (typeof v === "string") return { left: v, center: "", right: "{pagina}" };
|
|
26
|
+
return { left: v.left || "", center: v.center || "", right: v.right || "" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hfFill(deckLike) {
|
|
30
|
+
const m = hfModal();
|
|
31
|
+
for (const k of ["author", "event", "department", "date"]) m.querySelector(`[data-deck="${k}"]`).value = deckLike[k] || "";
|
|
32
|
+
for (const kind of ["header", "footer"]) {
|
|
33
|
+
const v = hfSlotsFrom(deckLike[kind], kind, deckLike);
|
|
34
|
+
for (const pos of ["left", "center", "right"]) m.querySelector(`[data-slot="${kind}.${pos}"]`).value = v[pos];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// o que os campos do modal significam como deck (footer/header "limpos": null = padrão, false = nenhum)
|
|
39
|
+
function hfRead() {
|
|
40
|
+
const m = hfModal();
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const k of ["author", "event", "department", "date"]) out[k] = m.querySelector(`[data-deck="${k}"]`).value.trim();
|
|
43
|
+
for (const kind of ["header", "footer"]) {
|
|
44
|
+
const v = Object.fromEntries(["left", "center", "right"].map((p) => [p, m.querySelector(`[data-slot="${kind}.${p}"]`).value.trim()]));
|
|
45
|
+
const empty = !v.left && !v.center && !v.right;
|
|
46
|
+
if (kind === "footer" && v.left === "{titulo}" && !v.center && v.right === "{pagina}") out.footer = null;
|
|
47
|
+
else if (empty) out[kind] = kind === "footer" ? false : null;
|
|
48
|
+
else out[kind] = Object.fromEntries(Object.entries(v).filter(([, x]) => x));
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hfApplyTo(deck, vals) {
|
|
54
|
+
for (const [k, v] of Object.entries(vals)) {
|
|
55
|
+
if (v == null || v === "") delete deck[k];
|
|
56
|
+
else deck[k] = v;
|
|
57
|
+
}
|
|
58
|
+
return deck;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function openHeaderFooter() {
|
|
62
|
+
const m = hfModal();
|
|
63
|
+
const presets = m.querySelector(".hf-presets");
|
|
64
|
+
if (!presets.childElementCount) {
|
|
65
|
+
for (const p of HF_PRESETS) {
|
|
66
|
+
const b = document.createElement("button");
|
|
67
|
+
b.type = "button";
|
|
68
|
+
b.className = "hf-preset";
|
|
69
|
+
b.dataset.preset = p.id;
|
|
70
|
+
b.innerHTML = `<b></b><span></span>`;
|
|
71
|
+
b.querySelector("b").textContent = p.name;
|
|
72
|
+
b.querySelector("span").textContent = p.desc;
|
|
73
|
+
b.onclick = () => {
|
|
74
|
+
const cur = hfRead();
|
|
75
|
+
hfFill({ ...cur, footer: p.footer, header: p.header });
|
|
76
|
+
presets.querySelectorAll(".hf-preset").forEach((x) => x.classList.toggle("active", x === b));
|
|
77
|
+
hfPreview();
|
|
78
|
+
};
|
|
79
|
+
presets.append(b);
|
|
80
|
+
}
|
|
81
|
+
const chips = m.querySelector(".hf-tokens");
|
|
82
|
+
for (const [tok, label] of HF_TOKENS) {
|
|
83
|
+
const c = document.createElement("button");
|
|
84
|
+
c.type = "button";
|
|
85
|
+
c.className = "hf-token";
|
|
86
|
+
c.dataset.token = tok;
|
|
87
|
+
c.textContent = label;
|
|
88
|
+
c.title = tok;
|
|
89
|
+
c.onmousedown = (e) => e.preventDefault(); // não rouba o foco do campo
|
|
90
|
+
c.onclick = () => {
|
|
91
|
+
const inp = hfLastInput || m.querySelector('[data-slot="footer.left"]');
|
|
92
|
+
const a = inp.selectionStart ?? inp.value.length, z = inp.selectionEnd ?? a;
|
|
93
|
+
inp.value = inp.value.slice(0, a) + tok + inp.value.slice(z);
|
|
94
|
+
inp.focus();
|
|
95
|
+
inp.setSelectionRange(a + tok.length, a + tok.length);
|
|
96
|
+
hfPreview();
|
|
97
|
+
};
|
|
98
|
+
chips.append(c);
|
|
99
|
+
}
|
|
100
|
+
m.querySelectorAll("input").forEach((inp) => {
|
|
101
|
+
inp.addEventListener("focus", () => { if (inp.dataset.slot) hfLastInput = inp; });
|
|
102
|
+
inp.addEventListener("input", () => { clearTimeout(hfTimer); hfTimer = setTimeout(hfPreview, 250); });
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
hfFill(state.deck);
|
|
106
|
+
m.classList.remove("hidden");
|
|
107
|
+
hfPreview();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function closeHeaderFooter() {
|
|
111
|
+
hfModal().classList.add("hidden");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function hfPreview() {
|
|
115
|
+
const stage = hfModal().querySelector(".hf-stage");
|
|
116
|
+
const spec = hfApplyTo(JSON.parse(JSON.stringify(state.deck)), hfRead());
|
|
117
|
+
// na prévia, um slide que mostra rodapé (a capa não mostra)
|
|
118
|
+
let i = state.currentSlideIndex;
|
|
119
|
+
const NO_BARS = ["cover", "section", "end", "image", "canvas", "full", "headline"];
|
|
120
|
+
if (NO_BARS.includes(spec.slides[i]?.layout)) i = Math.max(0, spec.slides.findIndex((s) => !NO_BARS.includes(s.layout)));
|
|
121
|
+
try {
|
|
122
|
+
const r = await (await fetch("api/render-slide", { method: "POST", headers: { "Content-Type": "application/json" },
|
|
123
|
+
body: JSON.stringify({ slide: spec.slides[i], index: i, spec }) })).json();
|
|
124
|
+
stage.innerHTML = `<div class="thumb-render">${r.html}</div>`;
|
|
125
|
+
requestAnimationFrame(() => fitRendered(stage));
|
|
126
|
+
stage.style.setProperty("--thumb-scale", String(stage.clientWidth / 1920));
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function applyHeaderFooter() {
|
|
131
|
+
hfApplyTo(state.deck, hfRead());
|
|
132
|
+
closeHeaderFooter();
|
|
133
|
+
syncDeckToServer();
|
|
134
|
+
renderCurrentSlide();
|
|
135
|
+
renderThumbnails();
|
|
136
|
+
showToast("Cabeçalho e rodapé aplicados em todos os slides", 2200);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { openHeaderFooter, closeHeaderFooter, applyHeaderFooter };
|
|
140
|
+
};
|
|
@@ -500,6 +500,7 @@
|
|
|
500
500
|
<div id="chat-attachments" class="chat-attachments" hidden></div>
|
|
501
501
|
<div class="chat-compose">
|
|
502
502
|
<textarea id="chat-input" class="chat-field" rows="1" placeholder="Pergunte, peça uma mudança, anexe um arquivo ou cole um link…" autocomplete="off"></textarea>
|
|
503
|
+
<button type="button" id="chat-critique" class="icon-btn" title="Leitura crítica do material anexado: inconsistências, achados escondidos, perguntas prováveis da banca (nada muda nos slides)"><i class="ic" data-ic="scan-search"></i></button>
|
|
503
504
|
<button type="button" id="chat-attach" class="icon-btn" title="Anexar imagem ou documento (pdf, docx, xlsx, pptx, txt)"><i class="ic" data-ic="image-plus"></i></button>
|
|
504
505
|
<button type="submit" id="chat-send" class="icon-btn icon-btn-primary" title="Enviar (Enter)"><i class="ic" data-ic="send"></i></button>
|
|
505
506
|
</div>
|
|
@@ -840,6 +841,7 @@
|
|
|
840
841
|
|
|
841
842
|
<script src="fit.js"></script>
|
|
842
843
|
<script src="ui-icons.js"></script>
|
|
844
|
+
<script src="update-notice.js"></script>
|
|
843
845
|
<script src="calc-fields.js"></script>
|
|
844
846
|
<script src="art-preview.js"></script>
|
|
845
847
|
<script src="api-collections.js"></script>
|
|
@@ -867,6 +869,9 @@
|
|
|
867
869
|
<script src="workspace.js"></script>
|
|
868
870
|
<script src="studio-awareness.js"></script>
|
|
869
871
|
<script src="chat.js"></script>
|
|
872
|
+
<script src="provenance.js"></script>
|
|
873
|
+
<script src="header-footer.js"></script>
|
|
874
|
+
<script src="preferences-ui.js"></script>
|
|
870
875
|
<script src="app.js"></script>
|
|
871
876
|
</body>
|
|
872
877
|
</html>
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
<div class="ldlg-back" id="dlg"><div class="ldlg" role="dialog" aria-modal="true" aria-labelledby="dialog-heading"></div></div>
|
|
51
51
|
<div class="ltoast" id="toast" role="status"></div>
|
|
52
52
|
<script src="ui-icons.js"></script>
|
|
53
|
+
<script src="update-notice.js"></script>
|
|
53
54
|
<script src="library.js"></script>
|
|
54
55
|
</body>
|
|
55
56
|
</html>
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
const match = (d) => !q || d.title.toLowerCase().includes(q);
|
|
112
112
|
const main = $("#main");
|
|
113
113
|
if (view === "lixeira") {
|
|
114
|
-
main.innerHTML = `<div class="head"><div><h1>Lixeira</h1><div class="sub">Fica aqui por
|
|
114
|
+
main.innerHTML = `<div class="head"><div><h1>Lixeira</h1><div class="sub">Fica aqui por um dia; depois é apagado de vez.</div></div>${data.trash.length ? `<button class="lib-btn ghost danger" data-empty-trash>${ic("trash-2")}Esvaziar lixeira</button>` : ""}</div>` +
|
|
115
115
|
(data.trash.length ? data.trash.map((t) => `<div class="trash-row"><div class="t"><b>${esc(t.title)}</b><div class="sub">Era de ${esc(t.topic || NO_TOPIC.name)} · ${t.slides || 0} slides · excluída ${ago(t.deleted)}</div></div>
|
|
116
116
|
<button class="lib-btn ghost" data-restore="${esc(t.id)}">${ic("rotate-ccw")}Restaurar</button><button class="lib-btn ghost danger" data-purge="${esc(t.id)}">Excluir de vez</button></div>`).join("")
|
|
117
117
|
: `<div class="empty"><div class="big">${ic("trash-2")}</div><h2>Lixeira vazia</h2></div>`);
|
|
@@ -211,7 +211,7 @@
|
|
|
211
211
|
if (a === "open" || a === "present") return openEditor(id, a === "present");
|
|
212
212
|
if (a === "rename") return nameDialog("Renomear apresentação", d.title, (title) => act(() => api("api/library/decks/rename", { id, title }), "Renomeada"));
|
|
213
213
|
if (a === "dup") return act(() => api("api/library/decks/duplicate", { id }), "Cópia criada");
|
|
214
|
-
if (a === "trash") return act(() => api("api/library/decks/trash", { id }), "Na lixeira — dá para restaurar por
|
|
214
|
+
if (a === "trash") return act(() => api("api/library/decks/trash", { id }), "Na lixeira — dá para restaurar por um dia");
|
|
215
215
|
});
|
|
216
216
|
m.querySelectorAll("[data-move]").forEach((b) => b.onclick = () => { closeMenus(); act(() => api("api/library/decks/move", { id, topic: b.dataset.move }), `Movida para ${topicOf(b.dataset.move).name}`); });
|
|
217
217
|
m.querySelectorAll("[data-dl]").forEach((b) => b.onclick = () => {
|
|
@@ -435,7 +435,8 @@
|
|
|
435
435
|
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
|
|
436
436
|
if (!line.trim()) continue;
|
|
437
437
|
const ev = JSON.parse(line);
|
|
438
|
-
|
|
438
|
+
// a etapa (ou as etapas que correm juntas) e o tempo: a geração com documento leva minutos
|
|
439
|
+
if (ev.type === "progress" && ev.text) status.textContent = `${ev.text}${ev.elapsed ? ` · ${Math.round(ev.elapsed / 1000)}s` : ""}`;
|
|
439
440
|
if (ev.type === "result") result = ev.data;
|
|
440
441
|
if (ev.type === "error") throw new Error(ev.error || "falhou");
|
|
441
442
|
}
|