sagadeck 1.2.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/LICENSE +21 -0
- package/README.md +305 -0
- package/SKILL.md +148 -0
- package/bin/sagadeck.js +511 -0
- package/docs/ANDAMENTO.md +1255 -0
- package/docs/EXPERIENCIAS.md +39 -0
- package/docs/PLANO-EXPERIENCIAS.md +39 -0
- package/docs/PLANO-TESTE-AULAS-TECNICAS.md +44 -0
- package/docs/PLANO-TESTE-DESINFORMACAO.md +38 -0
- package/docs/REFERENCIA.md +1131 -0
- package/package.json +59 -0
- package/src/adaptive-layouts.js +18 -0
- package/src/ai/art-direction.js +8 -0
- package/src/ai/commands.js +130 -0
- package/src/ai/context.js +245 -0
- package/src/ai/deck-ai.js +1135 -0
- package/src/ai/directions.js +74 -0
- package/src/ai/document-materials.js +49 -0
- package/src/ai/document-visuals.js +215 -0
- package/src/ai/evaluation.js +31 -0
- package/src/ai/ground.js +55 -0
- package/src/ai/llm.js +292 -0
- package/src/ai/quality.js +51 -0
- package/src/ai/relay.js +45 -0
- package/src/ai/staged-generation.js +24 -0
- package/src/ai/transform.js +858 -0
- package/src/ai/usage.js +17 -0
- package/src/ai/variety.js +80 -0
- package/src/ai/video-frames.js +24 -0
- package/src/ai/video-generation.js +126 -0
- package/src/ai/web-operation.js +31 -0
- package/src/algo-catalog.js +153 -0
- package/src/algo-trace.js +139 -0
- package/src/api-client.js +514 -0
- package/src/api-collections.js +14 -0
- package/src/api-demo.js +222 -0
- package/src/aspect.js +40 -0
- package/src/build.js +323 -0
- package/src/carousel.js +56 -0
- package/src/chrome.js +65 -0
- package/src/code-highlight.js +68 -0
- package/src/code-lab-browser.js +22 -0
- package/src/code-lab.js +3 -0
- package/src/code-language.js +52 -0
- package/src/csv.js +74 -0
- package/src/decision-lab.js +20 -0
- package/src/deck-file.js +119 -0
- package/src/diagram/napkin-examples.js +13 -0
- package/src/diagram/napkin.js +539 -0
- package/src/docx.js +128 -0
- package/src/dynamics/git-sim.js +200 -0
- package/src/dynamics/layouts.js +112 -0
- package/src/elements.js +554 -0
- package/src/experiences.js +124 -0
- package/src/exploration.js +31 -0
- package/src/export/browser.js +53 -0
- package/src/export/estudo.js +67 -0
- package/src/export/pptx.js +475 -0
- package/src/export/roteiro.js +40 -0
- package/src/export/shots.js +182 -0
- package/src/figures/charts.js +383 -0
- package/src/figures/diagrams.js +117 -0
- package/src/figures/icons.js +166 -0
- package/src/figures/material-pictos.js +20 -0
- package/src/figures/pictos.js +255 -0
- package/src/figures/points.js +106 -0
- package/src/figures/qr.js +29 -0
- package/src/figures/ufmap.js +48 -0
- package/src/fiscal/autofix.js +377 -0
- package/src/fiscal/normalize.js +115 -0
- package/src/graph-lab.js +24 -0
- package/src/hologram.js +30 -0
- package/src/identity.js +86 -0
- package/src/import/crop.js +59 -0
- package/src/import/index.js +132 -0
- package/src/import/legacy-word.js +42 -0
- package/src/import/merge.js +45 -0
- package/src/import/office.js +123 -0
- package/src/import/omml.js +69 -0
- package/src/import/pdf-render.js +56 -0
- package/src/import/pptx.js +701 -0
- package/src/import/xml.js +42 -0
- package/src/infographic.js +401 -0
- package/src/layouts.js +723 -0
- package/src/lessons.js +171 -0
- package/src/library.js +399 -0
- package/src/markup.js +116 -0
- package/src/master.js +214 -0
- package/src/mcp/server.js +371 -0
- package/src/meeting.js +42 -0
- package/src/motion.js +56 -0
- package/src/package.js +116 -0
- package/src/playground.js +19 -0
- package/src/portal-scene.js +20 -0
- package/src/preferences.js +61 -0
- package/src/protect.js +51 -0
- package/src/purpose.js +24 -0
- package/src/pytrace.js +1092 -0
- package/src/research/duckduckgo.js +73 -0
- package/src/research/research.js +202 -0
- package/src/research/svg-image.js +17 -0
- package/src/research/visuals.js +52 -0
- package/src/runtime/api-collection-ui.js +5 -0
- package/src/runtime/api-collections.js +28 -0
- package/src/runtime/api-core.js +530 -0
- package/src/runtime/api-ui.js +966 -0
- package/src/runtime/base.css +1774 -0
- package/src/runtime/calc.js +139 -0
- package/src/runtime/code-lab.js +371 -0
- package/src/runtime/decision-lab.js +49 -0
- package/src/runtime/diagram.js +341 -0
- package/src/runtime/fit.js +268 -0
- package/src/runtime/fonts/caveat.css +2 -0
- package/src/runtime/fonts/comic-neue.css +3 -0
- package/src/runtime/fonts/fredoka.css +2 -0
- package/src/runtime/fonts/index.json +10 -0
- package/src/runtime/fonts/inter.css +2 -0
- package/src/runtime/fonts/patrick-hand.css +2 -0
- package/src/runtime/fonts/plus-jakarta-sans.css +2 -0
- package/src/runtime/fonts/press-start-2p.css +2 -0
- package/src/runtime/fonts/vt323.css +2 -0
- package/src/runtime/formula.js +148 -0
- package/src/runtime/graph-lab.js +72 -0
- package/src/runtime/meeting.css +7 -0
- package/src/runtime/meeting.js +10 -0
- package/src/runtime/motion.js +15 -0
- package/src/runtime/playground.js +11 -0
- package/src/runtime/points.js +19 -0
- package/src/runtime/portal-scene.css +6 -0
- package/src/runtime/portal-scene.js +22 -0
- package/src/runtime/runtime.js +827 -0
- package/src/runtime/science.js +89 -0
- package/src/runtime/skins/aluminio.css +31 -0
- package/src/runtime/skins/arcade.css +25 -0
- package/src/runtime/skins/aurora.css +17 -0
- package/src/runtime/skins/bauhaus.css +19 -0
- package/src/runtime/skins/editorial.css +19 -0
- package/src/runtime/skins/jornal.css +17 -0
- package/src/runtime/skins/manual-noite.css +14 -0
- package/src/runtime/skins/manual.css +14 -0
- package/src/runtime/skins/noite.css +21 -0
- package/src/runtime/skins/oceano.css +17 -0
- package/src/runtime/skins/pop.css +23 -0
- package/src/runtime/skins/prata.css +18 -0
- package/src/runtime/skins/prisma.css +28 -0
- package/src/runtime/skins/rabisco.css +18 -0
- package/src/runtime/skins/relevo.css +33 -0
- package/src/runtime/skins/sinal.css +16 -0
- package/src/runtime/skins/terminal.css +23 -0
- package/src/runtime/vendor/KATEX-LICENSE.txt +21 -0
- package/src/runtime/vendor/MERMAID-LICENSE.txt +21 -0
- package/src/runtime/vendor/PLOTLY-LICENSE.txt +21 -0
- package/src/runtime/vendor/katex.css +1 -0
- package/src/runtime/vendor/mermaid.min.js +3636 -0
- package/src/runtime/vendor/plotly.min.js +3620 -0
- package/src/runtime/video-player.js +35 -0
- package/src/science.js +162 -0
- package/src/studio/ai-response.js +36 -0
- package/src/studio/api-routes.js +152 -0
- package/src/studio/assets/contexto/leia-me.md +12 -0
- package/src/studio/assets/contexto/vendas.csv +7 -0
- package/src/studio/assets/imagens/colecao-arquitetura.png +0 -0
- package/src/studio/assets/imagens/colecao-cor.png +0 -0
- package/src/studio/assets/imagens/colecao-equipe.png +0 -0
- package/src/studio/assets/imagens/lavanda-cover.jpg +0 -0
- package/src/studio/code-routes.js +32 -0
- package/src/studio/command-approval.js +48 -0
- package/src/studio/demo-decks.js +98 -0
- package/src/studio/direction-routes.js +29 -0
- package/src/studio/errors.js +65 -0
- package/src/studio/exploration-demo.js +17 -0
- package/src/studio/exporting.js +105 -0
- package/src/studio/generation-report.js +10 -0
- package/src/studio/instance.js +48 -0
- package/src/studio/layout-samples.js +152 -0
- package/src/studio/project.js +371 -0
- package/src/studio/public/api-collections.js +28 -0
- package/src/studio/public/app.js +5265 -0
- package/src/studio/public/art-preview.js +10 -0
- package/src/studio/public/calc-fields.js +11 -0
- package/src/studio/public/chat-progress.js +47 -0
- package/src/studio/public/developer-fields.js +22 -0
- package/src/studio/public/explorer.js +443 -0
- package/src/studio/public/history.js +58 -0
- package/src/studio/public/index.html +867 -0
- package/src/studio/public/inspector.js +270 -0
- package/src/studio/public/lab-fields.js +5 -0
- package/src/studio/public/library.css +275 -0
- package/src/studio/public/library.html +55 -0
- package/src/studio/public/library.js +471 -0
- package/src/studio/public/merge-decks.js +155 -0
- package/src/studio/public/review-ui.js +100 -0
- package/src/studio/public/screenshot-editor.js +140 -0
- package/src/studio/public/share-ui.js +72 -0
- package/src/studio/public/slide-clipboard.js +23 -0
- package/src/studio/public/slide-form.js +1263 -0
- package/src/studio/public/studio-awareness.js +154 -0
- package/src/studio/public/studio-next.css +303 -0
- package/src/studio/public/style.css +1262 -0
- package/src/studio/public/ui-icons.js +2 -0
- package/src/studio/public/viewers.js +372 -0
- package/src/studio/public/visual-editor.js +326 -0
- package/src/studio/public/workspace.js +18 -0
- package/src/studio/public-files.js +1 -0
- package/src/studio/request-helpers.js +41 -0
- package/src/studio/server.js +1583 -0
- package/src/studio/share-routes.js +146 -0
- package/src/studio/slide-copy-routes.js +26 -0
- package/src/studio/slide-copy.js +30 -0
- package/src/studio/snapshot.js +160 -0
- package/src/studio/style-routes.js +103 -0
- package/src/studio/template-collections.js +97 -0
- package/src/studio/versions.js +95 -0
- package/src/studio/video-download.js +29 -0
- package/src/studio/visual-keys.js +52 -0
- package/src/studio/workspaces.js +39 -0
- package/src/table.js +93 -0
- package/src/templates/scaffold.js +372 -0
- package/src/themes.js +604 -0
- package/src/trace-view.js +450 -0
- package/src/uid.js +19 -0
- package/src/video-player.js +40 -0
- package/src/visual-edits.js +99 -0
- package/src/ws.js +134 -0
- package/templates/cenario/Texto no cen/303/241rio.yaml" +171 -0
- package/templates/cenario/imagens/cidade.svg +30 -0
- package/templates/cenario/imagens/pessoa.svg +15 -0
- package/templates/cenario-e-ciencia.yaml +47 -0
- package/templates/ensaio-api.yaml +160 -0
- package/templates/exemplo-alegre.yaml +75 -0
- package/templates/exemplo-keynote.yaml +90 -0
- package/templates/exemplo-napkin.yaml +139 -0
- package/templates/exemplo.yaml +228 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Link para ver uma apresentação, só leitura (Arquivo › Compartilhar link). Quem abre vê a apresentação pronta
|
|
2
|
+
// (passa os slides), sem o editor, sem a biblioteca de quem compartilhou e sem as notas do apresentador (a não ser que
|
|
3
|
+
// o link seja "com as notas"). Cada link é um código aleatório de 144 bits (ninguém adivinha), guardado em
|
|
4
|
+
// <biblioteca>/.compartilhados.json, e pode ser revogado.
|
|
5
|
+
// /ver/<código> no servidor (multiusuário), só quem entrou no portal (o nginx exige a sessão e manda o usuário)
|
|
6
|
+
// /publico/<código> qualquer pessoa com o link (só se o link foi criado assim; o nginx deixa passar sem sessão)
|
|
7
|
+
// No Studio local os dois abrem para quem alcança o Studio (esta máquina ou a rede, com --host).
|
|
8
|
+
// Buscador não acha: o endereço não está em lugar nenhum, a página diz noindex/nofollow e não manda o Referer.
|
|
9
|
+
// Quem abre também baixa (botão Baixar na página): PDF, PowerPoint, HTML para apresentar sem internet, material de
|
|
10
|
+
// estudo (PDF e HTML) e o .sagadeck (para abrir no sagadeck dela), em <link>/baixar/<formato>, com as mesmas regras
|
|
11
|
+
// do link. Sem as notas, nenhum arquivo as leva; o roteiro (que é feito delas) só existe no link "com as notas".
|
|
12
|
+
// Um arquivo por vez por link (gerar PDF/PPTX usa o Chrome do servidor).
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { buildHTML, loadSpec } from "../build.js";
|
|
17
|
+
import { esc } from "../markup.js";
|
|
18
|
+
import { iconSVG } from "../figures/icons.js";
|
|
19
|
+
import { sendExport } from "./exporting.js";
|
|
20
|
+
|
|
21
|
+
const VIEW = /^\/(ver|publico)\/([A-Za-z0-9_-]{16,64})(?:\/baixar\/([a-z-]+))?\/?$/;
|
|
22
|
+
// formatos para quem recebe o link (o roteiro e o "tudo" levam as notas: só no link com as notas)
|
|
23
|
+
const DOWNLOADS = [
|
|
24
|
+
{ kind: "pdf", label: "PDF", hint: "Um slide por página, tudo revelado" },
|
|
25
|
+
{ kind: "pptx", label: "PowerPoint (.pptx)", hint: "Editável no PowerPoint, com as animações dos cliques" },
|
|
26
|
+
{ kind: "html", label: "Para apresentar sem internet (HTML)", hint: "Um arquivo só, abre em qualquer navegador" },
|
|
27
|
+
{ kind: "estudo", label: "Material de estudo (PDF)", hint: "Cada slide inteiro e o texto de consulta" },
|
|
28
|
+
{ kind: "estudo-html", label: "Material de estudo (HTML)", hint: "O mesmo material num arquivo HTML, bom no celular" },
|
|
29
|
+
{ kind: "sagadeck", label: "Para abrir no sagadeck (.sagadeck)", hint: "A apresentação inteira, para importar e editar a sua cópia" },
|
|
30
|
+
{ kind: "roteiro", label: "Roteiro do apresentador (PDF)", hint: "Miniatura de cada slide com as notas", notas: true },
|
|
31
|
+
{ kind: "tudo", label: "Tudo (PowerPoint, PDF e roteiro)", hint: "Um .zip com o que se leva para apresentar", notas: true },
|
|
32
|
+
];
|
|
33
|
+
// botão Baixar na página do link (canto de cima, discreto; os cliques nele não passam o slide)
|
|
34
|
+
const downloadBar = (l) => {
|
|
35
|
+
const items = DOWNLOADS.filter((d) => !d.notas || l.notas);
|
|
36
|
+
return `<style>.sd-dl{position:fixed;top:14px;right:14px;z-index:2147483000;font:14px system-ui,sans-serif}.sd-dl>button{display:flex;align-items:center;gap:6px;padding:7px 12px;border:1px solid rgba(0,0,0,.15);border-radius:8px;background:rgba(255,255,255,.92);color:#1f2328;cursor:pointer;opacity:.55;transition:opacity .2s}.sd-dl>button:hover,.sd-dl.open>button{opacity:1}.sd-dl ul{display:none;list-style:none;margin:6px 0 0;padding:6px;background:#fff;border:1px solid rgba(0,0,0,.12);border-radius:10px;box-shadow:0 8px 28px rgba(0,0,0,.18);min-width:260px}.sd-dl.open ul{display:block}.sd-dl a{display:block;padding:8px 10px;border-radius:6px;color:#1f2328;text-decoration:none}.sd-dl a:hover{background:#eef2f7}.sd-dl a.busy{opacity:.5;pointer-events:none}@media print{.sd-dl{display:none}}</style>
|
|
37
|
+
<div class="sd-dl" id="sd-dl"><button type="button" aria-haspopup="menu" title="Baixar esta apresentação">${iconSVG("download", { size: 16, stroke: 2 })}Baixar</button><ul role="menu">${items.map((d) => `<li><a role="menuitem" data-kind="${d.kind}" href="#" title="${esc(d.hint)}">${esc(d.label)}</a></li>`).join("")}</ul></div>
|
|
38
|
+
<script>(function(){var b=document.getElementById("sd-dl");["click","keydown","pointerdown","mousedown","touchstart","wheel"].forEach(function(t){b.addEventListener(t,function(e){e.stopPropagation()})});
|
|
39
|
+
b.querySelector("button").onclick=function(){b.classList.toggle("open")};document.addEventListener("click",function(){b.classList.remove("open")});
|
|
40
|
+
b.querySelectorAll("a").forEach(function(a){a.onclick=function(e){e.preventDefault();var base=location.pathname;while(base.slice(-1)==="/")base=base.slice(0,-1);a.classList.add("busy");setTimeout(function(){a.classList.remove("busy")},4000);location.href=base+"/baixar/"+a.dataset.kind;b.classList.remove("open")}})})();</script>`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function shareStore(libraryRoot) {
|
|
44
|
+
const file = path.join(libraryRoot, ".compartilhados.json");
|
|
45
|
+
const read = () => { try { const j = JSON.parse(fs.readFileSync(file, "utf8")); return Array.isArray(j.links) ? j.links : []; } catch { return []; } };
|
|
46
|
+
const write = (links) => {
|
|
47
|
+
fs.mkdirSync(libraryRoot, { recursive: true });
|
|
48
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
49
|
+
fs.writeFileSync(tmp, JSON.stringify({ links }, null, 1));
|
|
50
|
+
fs.renameSync(tmp, file);
|
|
51
|
+
};
|
|
52
|
+
return { file, read, write };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const page = (status, title, text) => `<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>${esc(title)}</title><style>body{font:18px system-ui,sans-serif;display:grid;place-items:center;min-height:100vh;margin:0;background:#f6f7f9;color:#1f2328}main{max-width:32em;padding:24px;text-align:center}h1{font-size:22px}</style></head><body><main><h1>${esc(title)}</h1><p>${esc(text)}</p></main></body></html>`;
|
|
56
|
+
const NOINDEX = { "X-Robots-Tag": "noindex, nofollow, noarchive", "Referrer-Policy": "no-referrer", "Cache-Control": "private, no-store" };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {{ libraryRoot: string, multiuser: boolean, libraryOf: (user: string) => { resolveId: Function, idOf: Function } }} o
|
|
60
|
+
*/
|
|
61
|
+
export function shareRoutes({ libraryRoot, multiuser, libraryOf }) {
|
|
62
|
+
const store = shareStore(libraryRoot);
|
|
63
|
+
const ownerOf = (W) => (multiuser ? String(W.user || "") : "");
|
|
64
|
+
const deckRef = (W) => {
|
|
65
|
+
if (!W.file) return null;
|
|
66
|
+
const id = W.library.idOf(W.file);
|
|
67
|
+
if (id && !id.startsWith("..") && !path.isAbsolute(id)) return { id };
|
|
68
|
+
return multiuser ? null : { file: path.resolve(W.file) }; // local: deck fora da biblioteca vale pelo caminho
|
|
69
|
+
};
|
|
70
|
+
const sameDeck = (l, ref) => (ref.id ? l.id === ref.id : l.file === ref.file);
|
|
71
|
+
const fileOf = (l) => {
|
|
72
|
+
if (l.file) return l.file;
|
|
73
|
+
try { return libraryOf(l.owner).resolveId(l.id); } catch { return null; }
|
|
74
|
+
};
|
|
75
|
+
const pathOf = (l) => `${l.publico ? "publico" : "ver"}/${l.token}`;
|
|
76
|
+
const view = (l) => ({ token: l.token, path: pathOf(l), publico: !!l.publico, notas: !!l.notas, created: l.created });
|
|
77
|
+
|
|
78
|
+
// a página do link: antes da conferência de usuário (o link público não tem usuário)
|
|
79
|
+
function serveView(req, res, pathname, user) {
|
|
80
|
+
const m = pathname.match(VIEW);
|
|
81
|
+
if (!m || !["GET", "HEAD"].includes(req.method)) return false;
|
|
82
|
+
const [, kind, token, download] = m;
|
|
83
|
+
const l = store.read().find((x) => x.token === token);
|
|
84
|
+
const send = (status, html, extra = {}) => { res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", ...NOINDEX, ...extra }); res.end(req.method === "HEAD" ? undefined : html); };
|
|
85
|
+
if (!l || (kind === "publico" && !l.publico)) return send(404, page(404, "Link indisponível", "Este link não existe ou foi revogado por quem compartilhou.")), true;
|
|
86
|
+
if (multiuser && kind === "ver" && !user) return send(401, page(401, "Entre pelo portal", "Este link é só para quem tem acesso ao portal.")), true;
|
|
87
|
+
const file = fileOf(l);
|
|
88
|
+
if (!file || !fs.existsSync(file)) return send(404, page(404, "Apresentação indisponível", "A apresentação foi apagada ou mudou de lugar.")), true;
|
|
89
|
+
let spec;
|
|
90
|
+
try { spec = loadSpec(file); } catch { return send(500, page(500, "Não deu para abrir", "A apresentação tem um erro e não pôde ser mostrada.")), true; }
|
|
91
|
+
if (!l.notas) spec.slides = spec.slides.map((s) => { if (!s || typeof s !== "object") return s; const { notes, ...rest } = s; return rest; });
|
|
92
|
+
if (download) return sendDownload(req, res, l, spec, file, download), true;
|
|
93
|
+
const html = buildHTML(spec).html.replace(/<head>/i, '<head><meta name="robots" content="noindex,nofollow"><meta name="referrer" content="no-referrer">');
|
|
94
|
+
// O Mermaid contém "</body>" em strings: inserir somente no fechamento real do HTML.
|
|
95
|
+
send(200, req.method === "HEAD" ? html : html.replace(/<\/body>(\s*<\/html>\s*)$/i, (_, end) => `${downloadBar(l)}</body>${end}`));
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const busy = new Set(); // um arquivo por vez por link
|
|
100
|
+
async function sendDownload(req, res, l, spec, file, kind) {
|
|
101
|
+
const fmt = DOWNLOADS.find((d) => d.kind === kind && (!d.notas || l.notas));
|
|
102
|
+
if (!fmt) { res.writeHead(404, { "Content-Type": "text/html; charset=utf-8", ...NOINDEX }); res.end(page(404, "Formato indisponível", "Este formato não está disponível neste link.")); return; }
|
|
103
|
+
if (busy.has(l.token)) { res.writeHead(429, { "Content-Type": "text/html; charset=utf-8", "Retry-After": "20", ...NOINDEX }); res.end(page(429, "Um momento", "Ainda estou gerando o arquivo anterior deste link. Tente de novo em alguns segundos.")); return; }
|
|
104
|
+
const name = path.basename(file).replace(/\.ya?ml$/i, "");
|
|
105
|
+
busy.add(l.token);
|
|
106
|
+
try {
|
|
107
|
+
if (kind === "html") {
|
|
108
|
+
const html = buildHTML(spec).html.replace(/<head>/i, '<head><meta name="robots" content="noindex,nofollow">');
|
|
109
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Content-Disposition": `attachment; filename="apresentacao.html"; filename*=UTF-8''${encodeURIComponent(name + ".html")}`, ...NOINDEX });
|
|
110
|
+
res.end(html);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
for (const [k, v] of Object.entries(NOINDEX)) res.setHeader(k, v);
|
|
114
|
+
await sendExport(res, kind, spec, name, { notes: !!l.notas });
|
|
115
|
+
} finally { busy.delete(l.token); }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// a API do Studio (a pessoa que compartilha): listar, criar e revogar os links da apresentação aberta
|
|
119
|
+
async function api({ req, res, pathname, W, readJSON }) {
|
|
120
|
+
if (!pathname.startsWith("/api/share")) return false;
|
|
121
|
+
const json = (status, body) => { res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify(body)); };
|
|
122
|
+
const owner = ownerOf(W), ref = deckRef(W);
|
|
123
|
+
const mine = (links) => links.filter((l) => l.owner === owner && ref && sameDeck(l, ref)).map(view);
|
|
124
|
+
if (pathname === "/api/share" && req.method === "GET") return json(200, { multiuser, links: ref ? mine(store.read()) : [], saved: !!ref }), true;
|
|
125
|
+
if (pathname === "/api/share" && req.method === "POST") {
|
|
126
|
+
if (!ref) return json(400, { error: "Salve a apresentação na biblioteca antes de compartilhar." }), true;
|
|
127
|
+
const body = await readJSON(req);
|
|
128
|
+
const link = { token: crypto.randomBytes(18).toString("base64url"), owner, ...ref, publico: multiuser && !!body.publico, notas: !!body.notas, created: new Date().toISOString() };
|
|
129
|
+
const links = store.read();
|
|
130
|
+
links.push(link);
|
|
131
|
+
store.write(links);
|
|
132
|
+
return json(200, { multiuser, link: view(link), links: mine(links) }), true;
|
|
133
|
+
}
|
|
134
|
+
if (pathname === "/api/share/revoke" && req.method === "POST") {
|
|
135
|
+
const { token } = await readJSON(req);
|
|
136
|
+
const links = store.read();
|
|
137
|
+
const l = links.find((x) => x.token === token);
|
|
138
|
+
if (!l || l.owner !== owner) return json(404, { error: "Link não encontrado." }), true; // o de outra pessoa nem existe para você
|
|
139
|
+
store.write(links.filter((x) => x !== l));
|
|
140
|
+
return json(200, { multiuser, links: mine(links.filter((x) => x !== l)) }), true;
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { serveView, api };
|
|
146
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { copySlideAssets } from "./slide-copy.js";
|
|
3
|
+
export async function slideCopyRoutes({pathname,req,res,W,readJSON,staleTab,slideClipboard,isBundledTemplate,materializePreview}) {
|
|
4
|
+
if (pathname === "/api/slides/clipboard" && req.method === "GET") {
|
|
5
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
6
|
+
res.end(JSON.stringify({ token: slideClipboard.get(W.user || '')?.token || null })); return true;
|
|
7
|
+
}
|
|
8
|
+
if (pathname === "/api/slides/copy" && req.method === "POST") {
|
|
9
|
+
const body = await readJSON(req);
|
|
10
|
+
if (staleTab(body, W, res)) return true;
|
|
11
|
+
if (!W.file || !body.slide) throw Error('Abra uma apresentação salva para copiar.');
|
|
12
|
+
const token = crypto.randomUUID();
|
|
13
|
+
slideClipboard.set(W.user || '', { token, file: W.file, slide: structuredClone(body.slide) });
|
|
14
|
+
res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ token })); return true;
|
|
15
|
+
}
|
|
16
|
+
if (pathname === "/api/slides/paste" && req.method === "POST") {
|
|
17
|
+
const body = await readJSON(req);
|
|
18
|
+
if (staleTab(body, W, res)) return true;
|
|
19
|
+
const clip = slideClipboard.get(W.user || '');
|
|
20
|
+
if (!clip || clip.token !== body.token) throw Error('Copie um slide novamente antes de colar.');
|
|
21
|
+
if (!W.file || isBundledTemplate(W.file)) materializePreview(W);
|
|
22
|
+
const slide = await copySlideAssets(clip.slide, clip.file, W.file);
|
|
23
|
+
res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ slide })); return true;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Transfere somente arquivos de mídia referenciados; nunca a pasta inteira do deck.
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
const media = /\.(png|jpe?g|svg|webp|gif|avif|mp4|webm|mov|mp3|wav|ogg|html?|css|m?js|woff2?|ttf)$/i;
|
|
6
|
+
const fields = new Set(['image','video','poster','audio','src','url']);
|
|
7
|
+
export async function copySlideAssets(slide, sourceFile, targetFile) {
|
|
8
|
+
const source=path.dirname(sourceFile), target=path.dirname(targetFile);
|
|
9
|
+
const prefix='copiados/'+crypto.randomUUID(), copied=new Set();
|
|
10
|
+
const local=v=>typeof v==='string'&&!/^(https?:|data:|blob:|\/\/|#)/i.test(v)&&media.test(v.split(/[?#]/)[0]);
|
|
11
|
+
async function file(ref,from=source){
|
|
12
|
+
const abs=path.resolve(from,ref.split(/[?#]/)[0]);const rel=path.relative(source,abs);
|
|
13
|
+
if(rel.startsWith('..')||path.isAbsolute(rel))throw Error('A mídia deve estar dentro da pasta da apresentação de origem.');
|
|
14
|
+
const real=await fs.realpath(abs);const rr=path.relative(source,real);if(rr.startsWith('..')||path.isAbsolute(rr))throw Error('Referência de mídia fora da apresentação.');
|
|
15
|
+
const out=path.join(target,prefix,rel);
|
|
16
|
+
if(!copied.has(abs)){copied.add(abs);await fs.mkdir(path.dirname(out),{recursive:true});await fs.copyFile(abs,out);
|
|
17
|
+
if(/\.(html?|css)$/i.test(abs)){const text=await fs.readFile(abs,'utf8');for(const m of text.matchAll(/(?:src|href)\s*=\s*["']([^"']+)["']|url\(\s*["']?([^)'"\s]+)["']?\s*\)/g)){const dep=m[1]||m[2];if(local(dep))await file(dep,path.dirname(abs));}}
|
|
18
|
+
}
|
|
19
|
+
return (prefix+'/'+rel.split(path.sep).join('/'))+(ref.match(/[?#].*$/)?.[0]||'');
|
|
20
|
+
}
|
|
21
|
+
async function walk(v,key){if(typeof v==='string'&&fields.has(key)&&local(v))return file(v);
|
|
22
|
+
if(typeof v==='string'&&key==='html'){
|
|
23
|
+
const refs=[...v.matchAll(/(?:src|href)\s*=\s*["']([^"']+)["']|url\(\s*["']?([^)'"\s]+)["']?\s*\)/g)];
|
|
24
|
+
for(const m of refs){const ref=m[1]||m[2];if(local(ref))v=v.replaceAll(ref,await file(ref));}return v;
|
|
25
|
+
}
|
|
26
|
+
if(Array.isArray(v))return Promise.all(v.map(x=>walk(x,key)));
|
|
27
|
+
if(v&&typeof v==='object'){const out={};for(const [k,x]of Object.entries(v))out[k]=await walk(x,k);return out;}return v;
|
|
28
|
+
}
|
|
29
|
+
const result=await walk(slide);result.uid=crypto.randomUUID();return result;
|
|
30
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// sagadeck Studio · "olhos" da IA: fotografa um slide renderizado de verdade (runtime completo, com
|
|
2
|
+
// fontes e figuras) para mandar ao modelo junto com o pedido. Um Chrome headless fica aberto e é
|
|
3
|
+
// reusado entre pedidos (a primeira foto demora ~1-2 s; as seguintes, bem menos).
|
|
4
|
+
import { slideSize } from "../aspect.js";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { buildHTML } from "../build.js";
|
|
10
|
+
import { findBrowser } from "../export/browser.js";
|
|
11
|
+
|
|
12
|
+
let browserPromise = null;
|
|
13
|
+
|
|
14
|
+
async function browser() {
|
|
15
|
+
if (!browserPromise) {
|
|
16
|
+
browserPromise = (async () => {
|
|
17
|
+
const { chromium } = await import("playwright-core");
|
|
18
|
+
const b = await chromium.launch({ executablePath: findBrowser() });
|
|
19
|
+
b.on("disconnected", () => { browserPromise = null; });
|
|
20
|
+
return b;
|
|
21
|
+
})().catch((e) => { browserPromise = null; throw e; });
|
|
22
|
+
}
|
|
23
|
+
return browserPromise;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Fotos do slide `index` do deck `spec`.
|
|
28
|
+
* @param {"final"|"steps"} mode final = tudo revelado; steps = uma foto por clique (0..n, até maxFrames)
|
|
29
|
+
* @returns {Promise<{ label: string, dataUrl: string }[]>}
|
|
30
|
+
*/
|
|
31
|
+
export async function slideSnapshots(spec, index, { mode = "final", maxFrames = 6, width = 960 } = {}) {
|
|
32
|
+
const { html } = buildHTML(spec);
|
|
33
|
+
const file = path.join(os.tmpdir(), `sagadeck-snap-${process.pid}-${Date.now()}.html`);
|
|
34
|
+
fs.writeFileSync(file, html);
|
|
35
|
+
const b = await browser();
|
|
36
|
+
const page = await b.newPage({ viewport: { width: 1920, height: slideSize(spec).h }, deviceScaleFactor: width / 1920 }); // deck.aspect
|
|
37
|
+
try {
|
|
38
|
+
// O palco real: exportação troca etapas interativas por resumos e ocultava o que a IA deveria conferir.
|
|
39
|
+
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
40
|
+
await page.goto(`${pathToFileURL(file).href}#${index + 1}`, { waitUntil: "load" });
|
|
41
|
+
await page.addStyleTag({ content: '#hud,#laser,.draw-fab,.draw-toolbar,#draw-canvas{display:none!important}' });
|
|
42
|
+
await page.waitForFunction(() => window.sagadeck && typeof window.sagadeck.goto === "function", null, { timeout: 10000 });
|
|
43
|
+
await page.evaluate(() => window.SagaScienceReady);
|
|
44
|
+
await page.evaluate(() => window.SagaDiagramsReady);
|
|
45
|
+
await page.evaluate(() => document.fonts?.ready);
|
|
46
|
+
const steps = await page.evaluate((i) => window.sagadeck.steps(i) || 0, index);
|
|
47
|
+
const frames = [];
|
|
48
|
+
const shot = async (label) => {
|
|
49
|
+
await page.waitForTimeout(120);
|
|
50
|
+
const state = await page.evaluate(() => {
|
|
51
|
+
const slide = document.querySelector('.slide.current');
|
|
52
|
+
const panel = slide?.querySelector('.lesson-panel.active, .dyn-frame.active');
|
|
53
|
+
return {
|
|
54
|
+
...(panel ? { stepText: panel.querySelector('.lesson-title, .algo-name')?.textContent, stepVisible: !!panel.getClientRects().length } : {}),
|
|
55
|
+
highlightedLines: [...(slide?.querySelectorAll('.code .cl.hl') || [])].map(line => Number(line.querySelector('.cn')?.textContent)),
|
|
56
|
+
...(slide?.querySelector('[data-graphlab]')?{graphDirected:JSON.parse(slide.querySelector('.gl-model').textContent).directed,graphResult:slide.querySelector('[data-gl-result]')?.textContent}:{}),
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
const buf = await page.screenshot({ type: "jpeg", quality: 80 });
|
|
60
|
+
frames.push({ label, state, dataUrl: `data:image/jpeg;base64,${buf.toString("base64")}` });
|
|
61
|
+
};
|
|
62
|
+
if (mode === 'exploration' && spec.slides[index]?.layout === 'calc') {
|
|
63
|
+
await page.evaluate(i => { window.sagadeck.goto(i, 0); document.querySelector('.slide.current [data-calc-reveal]')?.click(); }, index);
|
|
64
|
+
await shot('Estado inicial revelado');
|
|
65
|
+
await page.evaluate(() => document.querySelector('.slide.current [data-calc-freeze]')?.click());
|
|
66
|
+
for (let n = 0; n < Math.min((spec.slides[index].scenarios || []).length, maxFrames - 1); n++) {
|
|
67
|
+
await page.evaluate(n => document.querySelector(`.slide.current [data-calc-scenario="${n}"]`)?.click(), n);
|
|
68
|
+
await shot(`Cenário: ${spec.slides[index].scenarios[n].label}`);
|
|
69
|
+
}
|
|
70
|
+
} else if(mode==='exploration'&&spec.slides[index]?.layout==='graphlab'){
|
|
71
|
+
await shot('Rede inicial');
|
|
72
|
+
if(maxFrames>1){await page.click('[data-gl-action="route"]');await shot('Rota de menor custo');}
|
|
73
|
+
if(maxFrames>2){await page.locator('.gl-node').first().click();await page.click('[data-gl-action="focus"]');await shot('Foco no nó selecionado');}
|
|
74
|
+
if(maxFrames>3){await page.click('[data-gl-action="remove"]');await page.click('[data-gl-action="route"]');await shot('Rede após remover o nó');}
|
|
75
|
+
} else if(mode==='exploration'&&spec.slides[index]?.layout==='codelab'){
|
|
76
|
+
await shot('Programa e entrada inicial');
|
|
77
|
+
if(maxFrames>1){await page.evaluate(()=>{const n=document.querySelectorAll('.clab-stage .dyn-frame').length;for(let k=0;k<n;k++)document.querySelector('[data-clab-next]').click();});await shot('Resultado da execução inicial');}
|
|
78
|
+
} else if ((mode === "steps" || mode === 'exploration') && steps > 0) {
|
|
79
|
+
const count = Math.min(steps + 1, Math.max(2, maxFrames));
|
|
80
|
+
const samples = [...new Set(Array.from({ length: count }, (_, i) => Math.round(i * steps / (count - 1))))];
|
|
81
|
+
for (const k of samples) {
|
|
82
|
+
await page.evaluate(([i, kk]) => window.sagadeck.goto(i, kk), [index, k]);
|
|
83
|
+
await shot(k === 0 ? "antes do 1º clique" : `depois do clique ${k}`);
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
await page.evaluate(([i, n]) => window.sagadeck.goto(i, n, true), [index, steps]);
|
|
87
|
+
await shot(steps ? `todos os ${steps} cliques revelados` : "slide completo");
|
|
88
|
+
}
|
|
89
|
+
return frames;
|
|
90
|
+
} finally {
|
|
91
|
+
await page.close().catch(() => {});
|
|
92
|
+
fs.rm(file, { force: true }, () => {});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Diagramas (layout diagram) só se conferem desenhando: o Mermaid roda no navegador. Desenha os slides `indices`
|
|
98
|
+
* do deck e devolve o que deu errado, com o número do slide no deck.
|
|
99
|
+
* @returns {Promise<{ errors: {slide:number, error:string}[], warnings: {slide:number, warning:string}[] }>}
|
|
100
|
+
*/
|
|
101
|
+
export async function diagramCheck(spec, indices) {
|
|
102
|
+
const only = { ...spec, slides: indices.map((i) => spec.slides[i]) };
|
|
103
|
+
const { html } = buildHTML(only);
|
|
104
|
+
const file = path.join(os.tmpdir(), `sagadeck-dg-${process.pid}-${Date.now()}.html`);
|
|
105
|
+
fs.writeFileSync(file, html);
|
|
106
|
+
const b = await browser();
|
|
107
|
+
const page = await b.newPage({ viewport: { width: 1920, height: 1080 } });
|
|
108
|
+
try {
|
|
109
|
+
await page.goto(`${pathToFileURL(file).href}?export`, { waitUntil: "load" });
|
|
110
|
+
await page.waitForFunction(() => window.sagadeck && window.SagaDiagramsReady, null, { timeout: 10000 });
|
|
111
|
+
await page.evaluate(() => document.fonts?.ready);
|
|
112
|
+
await page.evaluate(() => window.SagaDiagramsReady);
|
|
113
|
+
const r = await page.evaluate(() => ({ errors: window.sagadeckDiagramErrors || [], warnings: window.sagadeckDiagramWarnings || [] }));
|
|
114
|
+
const real = (x) => ({ ...x, slide: indices[x.slide - 1] + 1 }); // número no deck de verdade
|
|
115
|
+
return { errors: r.errors.map(real), warnings: r.warnings.map(real) };
|
|
116
|
+
} finally {
|
|
117
|
+
await page.close().catch(() => {});
|
|
118
|
+
fs.rm(file, { force: true }, () => {});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* O fiscal de layout (src/export/shots.js: inPageCheck) em todos os slides do deck, com tudo revelado: texto que vaza
|
|
124
|
+
* ou é cortado, fora do slide, passando da margem, um por cima do outro, letra miúda, pouco contraste. Medido no
|
|
125
|
+
* navegador, sem modelo: é exato e de graça.
|
|
126
|
+
* @returns {Promise<{ slide:number, issues:{kind:string,text:string,px?:number}[] }[]>}
|
|
127
|
+
*/
|
|
128
|
+
export async function layoutCheck(spec) {
|
|
129
|
+
const { inPageCheck } = await import("../export/shots.js");
|
|
130
|
+
const { html } = buildHTML(spec);
|
|
131
|
+
const file = path.join(os.tmpdir(), `sagadeck-lc-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.html`);
|
|
132
|
+
fs.writeFileSync(file, html);
|
|
133
|
+
const b = await browser();
|
|
134
|
+
const page = await b.newPage({ viewport: { width: 1920, height: slideSize(spec).h } });
|
|
135
|
+
try {
|
|
136
|
+
await page.goto(`${pathToFileURL(file).href}?export`, { waitUntil: "load" });
|
|
137
|
+
await page.waitForFunction(() => window.sagadeck && typeof window.sagadeck.goto === "function", null, { timeout: 10000 });
|
|
138
|
+
await page.evaluate(() => window.SagaScienceReady);
|
|
139
|
+
await page.evaluate(() => window.SagaDiagramsReady);
|
|
140
|
+
await page.evaluate(() => document.fonts?.ready);
|
|
141
|
+
const n = await page.evaluate(() => window.sagadeck.n);
|
|
142
|
+
const out = [];
|
|
143
|
+
for (let i = 0; i < n; i++) {
|
|
144
|
+
const steps = await page.evaluate((j) => window.sagadeck.steps(j) || 0, i);
|
|
145
|
+
await page.evaluate(([j, k]) => window.sagadeck.goto(j, k, true), [i, steps]);
|
|
146
|
+
await page.waitForTimeout(80);
|
|
147
|
+
const issues = await page.evaluate(inPageCheck, i);
|
|
148
|
+
if (issues.length) out.push({ slide: i + 1, issues });
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
} finally {
|
|
152
|
+
await page.close().catch(() => {});
|
|
153
|
+
fs.rm(file, { force: true }, () => {});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function closeSnapshots() {
|
|
158
|
+
if (browserPromise) (await browserPromise).close().catch(() => {});
|
|
159
|
+
browserPromise = null;
|
|
160
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Rotas da revisão das mudanças (aceitar / desfazer, inclusive a proposta pendente ao lado do original) e dos estilos
|
|
2
|
+
// da pessoa (salvar, aplicar, tirar, padrão das novas). Saiu de server.js: devolve true quando a rota era daqui.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import YAML from "yaml";
|
|
6
|
+
import { styleFromImport } from "../master.js";
|
|
7
|
+
|
|
8
|
+
export async function styleRoutes({ req, res, pathname, W, persist, readJSON }) {
|
|
9
|
+
// revisão: aceitar (a marca sai) ou desfazer (slide novo sai; alterado volta ao original de original/original.yaml)
|
|
10
|
+
if (pathname === "/api/review" && req.method === "POST") {
|
|
11
|
+
const reply = (code, obj) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(obj)); return true; };
|
|
12
|
+
try {
|
|
13
|
+
const b = await readJSON(req);
|
|
14
|
+
const slides = W.spec?.slides || [];
|
|
15
|
+
// proposta pendente (a transformação deixou o original ao lado, porque faltava algo dele): aceitar tira o
|
|
16
|
+
// original do par; desfazer tira a proposta
|
|
17
|
+
const acceptOne = (s) => {
|
|
18
|
+
const pair = new Set(s.review?.status === "pendente" && Array.isArray(s.review.pair) ? s.review.pair : []);
|
|
19
|
+
delete s.review;
|
|
20
|
+
if (pair.size) W.spec.slides = W.spec.slides.filter((x) => !pair.has(x.uid));
|
|
21
|
+
};
|
|
22
|
+
if (b.action === "acceptAll") [...slides].forEach((s) => s.review && acceptOne(s));
|
|
23
|
+
else {
|
|
24
|
+
const s = slides[b.idx];
|
|
25
|
+
if (!s?.review) throw new Error("Este slide não tem mudança para validar.");
|
|
26
|
+
if (b.action === "accept") acceptOne(s);
|
|
27
|
+
else if (b.action === "reject") {
|
|
28
|
+
if (s.review.status === "novo" || s.review.status === "pendente" || s.review.original == null) slides.splice(b.idx, 1);
|
|
29
|
+
else {
|
|
30
|
+
const origFile = W.file && path.join(path.dirname(W.file), "original", "original.yaml");
|
|
31
|
+
if (!origFile || !fs.existsSync(origFile)) throw new Error("Não achei o original (original/original.yaml) para desfazer.");
|
|
32
|
+
const orig = YAML.parse(fs.readFileSync(origFile, "utf8"));
|
|
33
|
+
const back = orig.slides?.[Number(s.review.original) - 1];
|
|
34
|
+
if (!back) throw new Error(`O original não tem o slide ${s.review.original}.`);
|
|
35
|
+
slides[b.idx] = back;
|
|
36
|
+
}
|
|
37
|
+
} else throw new Error("ação desconhecida");
|
|
38
|
+
}
|
|
39
|
+
persist(W);
|
|
40
|
+
return reply(200, { ok: true, spec: W.spec });
|
|
41
|
+
} catch (e) { return reply(400, { error: e.message }); }
|
|
42
|
+
}
|
|
43
|
+
// estilos da pessoa (tema + mestre: src/master.js), guardados na biblioteca e aplicáveis em qualquer deck
|
|
44
|
+
if (pathname.startsWith("/api/styles")) {
|
|
45
|
+
const reply = (code, obj) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(obj)); return true; };
|
|
46
|
+
try {
|
|
47
|
+
if (pathname === "/api/styles" && req.method === "GET") return reply(200, { styles: W.library.listStyles(), current: W.spec?.style || null, canExtract: !!(W.spec?.master || W.spec?.import) });
|
|
48
|
+
const b = await readJSON(req);
|
|
49
|
+
if (!W.file) throw new Error("Abra uma apresentação da biblioteca primeiro.");
|
|
50
|
+
const dir = path.dirname(W.file);
|
|
51
|
+
if (pathname === "/api/styles/save") {
|
|
52
|
+
const name = String(b.name || W.spec.style?.name || W.spec.import?.from?.replace(/\.[a-z]+$/i, "") || W.spec.title || "Meu estilo").slice(0, 80);
|
|
53
|
+
const style = W.spec.master ? { name, theme: W.spec.theme, master: W.spec.master, from: W.spec.import?.from || null } : styleFromImport(W.spec, { name });
|
|
54
|
+
return reply(200, W.library.saveStyle(style, dir));
|
|
55
|
+
}
|
|
56
|
+
if (pathname === "/api/styles/default") return reply(200, { ok: true, default: W.library.setDefaultStyle(b.id || null) });
|
|
57
|
+
if (pathname === "/api/styles/apply") {
|
|
58
|
+
W.spec = W.library.applyStyleTo(W.spec, dir, b.id);
|
|
59
|
+
persist(W);
|
|
60
|
+
return reply(200, { ok: true, spec: W.spec });
|
|
61
|
+
}
|
|
62
|
+
if (pathname === "/api/styles/remove") {
|
|
63
|
+
delete W.spec.master; delete W.spec.style;
|
|
64
|
+
if (b.theme) W.spec.theme = b.theme;
|
|
65
|
+
persist(W);
|
|
66
|
+
return reply(200, { ok: true, spec: W.spec });
|
|
67
|
+
}
|
|
68
|
+
return reply(404, { error: "rota de estilo desconhecida" });
|
|
69
|
+
} catch (e) { return reply(400, { error: e.message }); }
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// a IA decidiu mexer no estilo pelo chat (estilo: aplicar | salvar | tirar | padrao); devolve a resposta do chat
|
|
75
|
+
export function styleAction({ W, spec, result, persist, isBundledTemplate }) {
|
|
76
|
+
const { action, name } = result.style;
|
|
77
|
+
const norm = (v) => String(v || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
|
|
78
|
+
const find = () => W.library.listStyles().find((s) => norm(s.name) === norm(name) || s.id === name) || W.library.listStyles().find((s) => norm(s.name).includes(norm(name)));
|
|
79
|
+
const say = (reply, extra = {}) => ({ ...result, reply, spec: W.spec || spec, ...extra });
|
|
80
|
+
if (!W.file || isBundledTemplate(W.file)) return say("Para usar estilos, a apresentação precisa estar salva na biblioteca.", { talk: true });
|
|
81
|
+
const dir = path.dirname(W.file);
|
|
82
|
+
try {
|
|
83
|
+
if (action === "aplicar") {
|
|
84
|
+
const st = find();
|
|
85
|
+
if (!st) return say(`Não achei o estilo "${name}". Os salvos: ${W.library.listStyles().map((s) => s.name).join(", ") || "nenhum"}.`, { talk: true });
|
|
86
|
+
W.spec = W.library.applyStyleTo(W.spec, dir, st.id); persist(W);
|
|
87
|
+
return say(`${result.reply}\nEstilo "${st.name}" aplicado: moldura, cores e fontes; o conteúdo ficou.`, { spec: W.spec });
|
|
88
|
+
}
|
|
89
|
+
if (action === "tirar") { delete W.spec.master; delete W.spec.style; W.spec.theme = W.spec.theme && typeof W.spec.theme === "string" ? W.spec.theme : "manual"; persist(W); return say(`${result.reply}\nEstilo tirado.`, { spec: W.spec }); }
|
|
90
|
+
if (action === "salvar") {
|
|
91
|
+
const style = W.spec.master ? { name, theme: W.spec.theme, master: W.spec.master, from: W.spec.import?.from || null } : styleFromImport(W.spec, { name });
|
|
92
|
+
const saved = W.library.saveStyle(style, dir);
|
|
93
|
+
return say(`${result.reply}\nEstilo "${saved.name}" salvo na biblioteca.`, { talk: true });
|
|
94
|
+
}
|
|
95
|
+
if (action === "padrao") {
|
|
96
|
+
const st = find();
|
|
97
|
+
if (!st) return say(`Não achei o estilo "${name}".`, { talk: true });
|
|
98
|
+
W.library.setDefaultStyle(st.id);
|
|
99
|
+
return say(`${result.reply}\nAs apresentações novas já nascem no estilo "${st.name}".`, { talk: true });
|
|
100
|
+
}
|
|
101
|
+
} catch (e) { return say(`Não deu: ${e.message}`, { talk: true }); }
|
|
102
|
+
return say("Não entendi o que fazer com o estilo.", { talk: true });
|
|
103
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Coleções originais: composições livres editáveis + páginas de conteúdo adaptável.
|
|
2
|
+
// Fotografias acompanham cada cópia na biblioteca; não dependem de URLs externas.
|
|
3
|
+
export const COLLECTION_NAMES = {
|
|
4
|
+
perspectiva: 'Perspectiva — corporativo fotográfico',
|
|
5
|
+
essencial: 'Essencial — minimalismo',
|
|
6
|
+
revista: 'Revista — editorial',
|
|
7
|
+
cromatico: 'Cromático — cor e fotografia',
|
|
8
|
+
tracos: 'Traços — geometria criativa',
|
|
9
|
+
};
|
|
10
|
+
export const COLLECTION_ASSETS = {
|
|
11
|
+
perspectiva: ['colecao-equipe.png'],
|
|
12
|
+
essencial: ['colecao-arquitetura.png'],
|
|
13
|
+
revista: ['colecao-arquitetura.png'],
|
|
14
|
+
cromatico: ['colecao-cor.png'],
|
|
15
|
+
tracos: ['colecao-equipe.png'],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const color = value => ['paper', 'ink', 'accent', 'alert'].includes(value) ? `c-${value}` : value;
|
|
19
|
+
const txt = (text, x, y, w, size = 40, extra = {}) => ({ text, x, y, w, size, fit: true, lh: 1.12, ...extra, ...(extra.color ? { color: color(extra.color) } : {}) });
|
|
20
|
+
const box = (x, y, w, h, fill, extra = {}) => ({ shape: 'rect', x, y, w, h, fill: color(fill), ...extra });
|
|
21
|
+
const photo = (name, x, y, w, h, extra = {}) => ({ image: `imagens/colecao-${name}.png`, alt: name === 'equipe' ? 'Mãos organizando ideias em uma mesa de trabalho' : name === 'cor' ? 'Xícara amarela e frutas em um cenário colorido' : 'Arquitetura de pedra com arcos e luz natural', x, y, w, h, fit: 'cover', ...extra });
|
|
22
|
+
const page = (title, elements) => ({ layout: 'canvas', title, tone: 'light', elements, notes: 'Modelo editável: troque os textos e as fotos pelos seus. Os números são ilustrativos.' });
|
|
23
|
+
const label = (text, x = 120, y = 90, color = 'ink') => txt(text, x, y, 1400, 24, { color, as: 'label' });
|
|
24
|
+
const heading = (text, x, y, w, size = 100, extra = {}) => txt(text, x, y, w, size, { as: 'title', ...extra });
|
|
25
|
+
const item = (title, text, value) => ({ title, text, ...(value ? { value } : {}) });
|
|
26
|
+
const priorities = [item('Uma pergunta melhor', 'Entenda o que precisa mudar antes de escolher a solução.', '01'), item('Um teste pequeno', 'Aprenda com uma experiência simples e um resultado observável.', '02'), item('Uma decisão clara', 'Combine o próximo passo, o responsável e o momento de revisar.', '03')];
|
|
27
|
+
const agenda = ['O contexto', 'A oportunidade', 'O experimento', 'O próximo passo'];
|
|
28
|
+
const palette = (paper, ink, accent, alert, family) => Object.fromEntries(Object.entries({ paper, ink, accent, alert, family }).map(([key, value]) => [key, Array.isArray(value) ? value.map(c => c.replace('#', '')) : value.replace('#', '')]));
|
|
29
|
+
|
|
30
|
+
// Estética de cada coleção para a geração com IA (modal "Deck com IA": estilo → tema + direção criativa).
|
|
31
|
+
export const COLLECTION_STYLE = {
|
|
32
|
+
perspectiva: { theme: 'jornal', direction: 'Corporativo fotográfico: fotografia grande de gente de verdade, contraste firme, uma ideia por slide. Considere jornal.' },
|
|
33
|
+
essencial: { theme: 'prata', direction: 'Minimalismo de estúdio: muito espaço vazio, uma frase por slide, detalhes em preto e branco. Considere prata.' },
|
|
34
|
+
revista: { theme: 'editorial', direction: 'Revista editorial: serifas grandes, capítulos, fotografia e respiro de página. Considere editorial.' },
|
|
35
|
+
cromatico: { theme: 'bauhaus', direction: 'Cor e fotografia: painéis sobrepostos, blocos de cor chapada, energia alegre. Considere bauhaus.' },
|
|
36
|
+
tracos: { theme: 'bauhaus', direction: 'Geometria criativa: molduras, caminhos visuais, números grandes, composição assimétrica. Considere bauhaus.' },
|
|
37
|
+
};
|
|
38
|
+
export function collectionDeck(kind) {
|
|
39
|
+
if (!Object.hasOwn(COLLECTION_NAMES, kind)) throw new Error('Coleção não encontrada.');
|
|
40
|
+
let theme, colors, slides;
|
|
41
|
+
if (kind === 'perspectiva') {
|
|
42
|
+
theme = 'jornal'; colors = palette('#faf9f6', '#20252b', '#c83c43', '#397684', ['#c83c43', '#397684', '#6b794e']);
|
|
43
|
+
slides = [
|
|
44
|
+
page('Um novo ponto de vista.', [photo('equipe', 780, 0, 1140, 1080), box(0, 0, 830, 1080, 'paper'), box(120, 180, 90, 8, 'accent'), label('PERSPECTIVA / ESTRATÉGIA', 120, 100), heading('Um novo\nponto de\nvista.', 120, 300, 690, 124), txt('Ideias claras. Decisões com contexto.', 120, 800, 620, 36)]),
|
|
45
|
+
page('Nossa conversa', [photo('equipe', 0, 0, 610, 1080, { style: 'filter:grayscale(1);' }), label('O CAMINHO', 730, 100), heading('Nossa conversa', 730, 170, 1050, 94), box(750, 385, 3, 450, 'accent'), ...agenda.flatMap((s, i) => [box(738, 385 + i * 145, 28, 28, 'accent', { shape: 'circle' }), txt(`0${i + 1}`, 810, 375 + i * 145, 100, 28, { color: 'accent' }), txt(s, 940, 366 + i * 145, 760, 46)])]),
|
|
46
|
+
page('O trabalho começa na pergunta.', [photo('equipe', 0, 0, 1920, 1080), box(0, 0, 960, 1080, '#20252b', { opacity: 0.94 }), label('UMA IDEIA PARA LEVAR', 120, 120, '#ffffff'), heading('O trabalho\ncomeça na\npergunta.', 120, 290, 760, 114, { color: '#ffffff' }), txt('Antes de acelerar, escolha a direção.', 120, 810, 740, 38, { color: '#ffffff' })]),
|
|
47
|
+
{ layout: 'mosaic', title: 'Três escolhas que importam', items: priorities },
|
|
48
|
+
page('Intenção e ação', [label('DIREÇÃO COMPARTILHADA'), heading('Intenção\ne ação', 120, 250, 650, 122), box(890, 0, 1030, 1080, 'ink'), box(830, 230, 970, 88, 'accent'), txt('O que queremos mudar', 910, 250, 840, 42, { color: '#ffffff', weight: 700 }), txt('Uma experiência mais simples para quem usa e mais clara para quem constrói.', 970, 380, 770, 43, { color: '#ffffff' }), box(830, 620, 970, 88, '#397684'), txt('Como vamos começar', 910, 640, 840, 42, { color: '#ffffff', weight: 700 }), txt('Um piloto com escopo pequeno, resultado visível e espaço para aprender.', 970, 770, 770, 43, { color: '#ffffff' })]),
|
|
49
|
+
{ layout: 'ribbon', title: 'Do plano à prática', items: priorities, source: 'Adicione ou remova etapas: a composição se ajusta.' },
|
|
50
|
+
page('Vamos escolher o próximo passo?', [box(0, 0, 1920, 1080, 'ink'), box(120, 155, 160, 12, 'accent'), heading('Vamos escolher\no próximo passo?', 120, 310, 1640, 140, { color: '#ffffff' }), txt('Uma decisão. Um responsável. Uma data.', 120, 820, 1400, 44, { color: '#ffffff' })]),
|
|
51
|
+
];
|
|
52
|
+
} else if (kind === 'essencial') {
|
|
53
|
+
theme = 'prata'; colors = palette('#fcfcfa', '#222724', '#526652', '#977252', ['#526652', '#977252', '#637581']);
|
|
54
|
+
slides = [
|
|
55
|
+
page('Menos ruído. Mais sentido.', [label('ESSENCIAL / ESTÚDIO DE IDEIAS'), box(120, 155, 1680, 2, '#d5d8d1'), photo('arquitetura', 1180, 275, 580, 610, { style: 'filter:grayscale(1);' }), heading('Menos ruído.\nMais sentido.', 120, 320, 1080, 111), txt('Espaço para a ideia principal aparecer.', 120, 800, 830, 34), label('01 / COMEÇAR', 120, 930)]),
|
|
56
|
+
page('O essencial primeiro', [label('UMA IDEIA, TRÊS PONTOS'), heading('O essencial\nprimeiro', 120, 250, 730, 108), ...priorities.flatMap((s, i) => [box(1020, 235 + i * 230, 740, 2, '#d5d8d1'), txt(s.value, 1020, 265 + i * 230, 90, 26), txt(s.title, 1140, 263 + i * 230, 600, 41, { weight: 600 }), txt(s.text, 1140, 330 + i * 230, 600, 30)])]),
|
|
57
|
+
page('Detalhes que fazem diferença', [label('OLHAR MAIS DE PERTO'), heading('Detalhes que\nfazem diferença', 120, 195, 1000, 88), photo('arquitetura', 120, 485, 900, 410, { style: 'filter:grayscale(1);' }), photo('arquitetura', 1230, 190, 490, 400), txt('Uma mesma ideia pode ser vista por mais de um ângulo.', 1230, 680, 490, 39)]),
|
|
58
|
+
{ layout: 'stats', title: 'Poucos números. Com contexto.', stats: [{ value: '03', label: 'hipóteses para testar' }, { value: '01', label: 'experiência por vez' }, { value: '04', label: 'semanas para aprender' }], source: 'Exemplo ilustrativo. Substitua pelos seus indicadores.' },
|
|
59
|
+
{ layout: 'mosaic', title: 'O que merece atenção', items: priorities.map(({ value, ...s }) => s) },
|
|
60
|
+
page('Uma ideia para levar', [label('ESSENCIAL / FECHAMENTO'), box(120, 220, 1680, 2, '#d5d8d1'), heading('Clareza também\né uma escolha.', 120, 370, 1660, 130), txt('Qual detalhe você pode simplificar hoje?', 120, 820, 1400, 44)]),
|
|
61
|
+
];
|
|
62
|
+
} else if (kind === 'revista') {
|
|
63
|
+
theme = 'editorial'; colors = palette('#f7f2ec', '#322d30', '#966575', '#685e4c', ['#966575', '#685e4c', '#5c7276']);
|
|
64
|
+
slides = [
|
|
65
|
+
page('Outras maneiras de olhar', [photo('arquitetura', 710, 0, 1210, 1080), box(0, 0, 720, 1080, 'paper'), label('REVISTA / VOLUME 01'), heading('Outras\nmaneiras\nde olhar', 120, 275, 1000, 125), txt('Uma coleção de ideias,\nângulos e possibilidades.', 120, 825, 600, 36)]),
|
|
66
|
+
page('Nesta edição', [box(0, 0, 770, 1080, '#e4d4d9'), heading('Nesta\nedição', 110, 300, 650, 148), label('SUMÁRIO', 950, 140), ...agenda.flatMap((s, i) => [txt(`0${i + 1}`, 950, 295 + i * 160, 130, 48, { color: 'accent' }), txt(s, 1120, 297 + i * 160, 650, 46), box(950, 388 + i * 160, 810, 2, '#d6c9ca')])]),
|
|
67
|
+
page('01 / Abrir o olhar', [photo('arquitetura', 0, 0, 1920, 1080), box(0, 0, 1110, 1080, 'paper', { style: 'clip-path:polygon(0 0,100% 0,73% 100%,0 100%);' }), label('PRIMEIRO CAPÍTULO'), heading('Abrir\no olhar', 120, 280, 700, 142), txt('01', 120, 715, 500, 190, { as: 'number', color: 'accent' })]),
|
|
68
|
+
page('Entre luz e espaço', [label('ENSAIO VISUAL'), photo('arquitetura', 120, 245, 740, 670), photo('arquitetura', 940, 120, 430, 420), box(1390, 120, 410, 420, '#e4d4d9'), txt('02', 1460, 220, 300, 170, { as: 'number', color: 'accent' }), heading('Entre luz\ne espaço', 940, 615, 810, 90), txt('Encontre a história nos detalhes.', 940, 860, 800, 34)]),
|
|
69
|
+
{ layout: 'ribbon', title: 'Um percurso em três atos', items: priorities },
|
|
70
|
+
page('O que fica depois?', [box(0, 0, 1920, 1080, '#e4d4d9'), label('ÚLTIMA PÁGINA / PRÓXIMA CONVERSA'), heading('O que fica\ndepois?', 120, 255, 1550, 170), txt('Uma boa pergunta pode ser o começo de tudo.', 120, 820, 1510, 43)]),
|
|
71
|
+
];
|
|
72
|
+
} else if (kind === 'cromatico') {
|
|
73
|
+
theme = 'bauhaus'; colors = palette('#fffaf0', '#252a43', '#e2573f', '#236c78', ['#e2573f', '#236c78', '#6751a8']);
|
|
74
|
+
slides = [
|
|
75
|
+
page('Dê cor à sua ideia.', [photo('cor', 0, 0, 1920, 1080), box(105, 165, 970, 680, 'paper'), label('CROMÁTICO / IDEIAS EM MOVIMENTO', 165, 225), heading('Dê cor\nà sua ideia.', 165, 345, 845, 130), box(930, 740, 650, 150, '#f7d64b'), txt('COMECE PELO INESPERADO', 980, 790, 560, 28, { weight: 700 })]),
|
|
76
|
+
page('Pequenos detalhes. Outra energia.', [box(0, 0, 1920, 1080, '#f7d64b'), photo('cor', 860, 125, 930, 780), label('A COR MUDA A CONVERSA'), heading('Pequenos\ndetalhes.\nOutra energia.', 120, 310, 920, 105), box(120, 835, 620, 100, 'paper'), txt('O simples também surpreende.', 155, 865, 560, 29)]),
|
|
77
|
+
page('Três ingredientes', [label('UMA RECEITA PARA EXPERIMENTAR'), heading('Três ingredientes', 120, 170, 1600, 98), ...priorities.flatMap((s, i) => [box(120 + i * 565, 380, 525, 540, ['#f7d64b', '#bce0cd', '#f2b6a5'][i]), txt(s.value, 160 + i * 565, 420, 420, 116, { as: 'number' }), txt(s.title, 160 + i * 565, 610, 420, 43, { weight: 700 }), txt(s.text, 160 + i * 565, 735, 420, 31)])]),
|
|
78
|
+
page('Olhe de novo.', [photo('cor', 0, 0, 1100, 1080), box(1100, 0, 820, 1080, '#bce0cd'), heading('Olhe\nde\nnovo.', 1190, 245, 650, 147), txt('O mesmo objeto.\nUma nova perspectiva.', 1190, 820, 600, 35)]),
|
|
79
|
+
{ layout: 'mosaic', title: 'Espaço para mais ideias', items: [...priorities, item('Uma conversa aberta', 'Convide outros olhares para a próxima versão.'), item('Um novo detalhe', 'Observe o que muda na experiência.')], source: 'Esta grade se adapta quando você adiciona ou remove itens.' },
|
|
80
|
+
page('Agora é com você.', [box(0, 0, 1920, 1080, '#f7d64b'), box(1240, 0, 680, 1080, '#e2573f'), box(1300, 280, 500, 500, '#bce0cd', { shape: 'circle' }), label('CROMÁTICO / PRÓXIMO MOVIMENTO'), heading('Agora\né com você.', 120, 310, 1110, 140), txt('Que ideia merece sair do papel?', 120, 825, 1060, 42)]),
|
|
81
|
+
];
|
|
82
|
+
} else {
|
|
83
|
+
theme = 'bauhaus'; colors = palette('#fafaf5', '#2d304c', '#476d98', '#b6574d', ['#476d98', '#b6574d', '#647d71']);
|
|
84
|
+
const ornaments = () => [box(130, 185, 1660, 700, 'paper', { stroke: '#696080', strokeWidth: 5 }), box(90, 750, 190, 190, '#f1d372', { shape: 'circle' }), ...[0, 1, 2].map(i => box(1510 + i * 80, 690, 28, 220, '#476d98', { rotate: 35 })), box(1510, 135, 40, 160, '#d98777'), box(1580, 135, 40, 160, '#d98777')];
|
|
85
|
+
slides = [
|
|
86
|
+
page('Ideias em construção', [...ornaments(), label('TRAÇOS / GEOMETRIA CRIATIVA', 280, 270), heading('Ideias em\nconstrução', 280, 410, 1300, 125), txt('Pessoas, caminhos e possibilidades.', 280, 750, 1200, 38)]),
|
|
87
|
+
page('Quatro pontos de partida', [label('O QUE NOS MOVE'), box(100, 225, 600, 105, '#f1d372'), heading('Pontos de\npartida', 120, 360, 550, 94), ...['Curiosidade', 'Clareza', 'Colaboração', 'Aprendizado'].flatMap((s, i) => { const x = 820 + (i % 2) * 510, y = 225 + Math.floor(i / 2) * 360; return [txt(`0${i + 1}`, x, y, 430, 100, { color: '#b6574d', as: 'number' }), txt(s, x, y + 130, 430, 39, { weight: 700 }), txt('Um princípio para orientar as escolhas do dia a dia.', x, y + 210, 410, 29)]; })]),
|
|
88
|
+
page('Gente que faz acontecer', [label('PESSOAS / ENCONTROS'), box(1100, 150, 680, 770, '#476d98'), photo('equipe', 1030, 100, 680, 770), heading('Gente que\nfaz acontecer', 120, 290, 830, 103), box(120, 630, 190, 12, '#d98777'), txt('Experiências diferentes.\nUma construção em comum.', 120, 740, 780, 41)]),
|
|
89
|
+
{ layout: 'infographic', shape: 'metro', title: 'Caminhos que se conectam', center: { title: 'Uma ideia', icon: 'lightbulb' }, items: priorities },
|
|
90
|
+
{ layout: 'mosaic', title: 'Nossos princípios, na prática', items: [...priorities, item('Compartilhar', 'Torne os aprendizados acessíveis a quem vai continuar.') ] },
|
|
91
|
+
page('A próxima forma é sua.', [...ornaments(), label('TRAÇOS / CONTINUA', 280, 270), heading('A próxima\nforma é sua.', 280, 405, 1300, 130), txt('Qual será o seu primeiro movimento?', 280, 755, 1200, 38)]),
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
// Os layouts adaptáveis já numeram os itens; evite repetir a numeração como indicador.
|
|
95
|
+
slides = slides.map(s => s.items ? { ...s, items: s.items.map(({ value, ...rest }) => rest) } : s);
|
|
96
|
+
return { title: COLLECTION_NAMES[kind], theme, palette: colors, duration: 12, maxWords: 140, slides };
|
|
97
|
+
}
|