Add source upload deck starter

This commit is contained in:
Codex 2026-06-08 22:09:12 -07:00
parent eb0a17f75e
commit 2501e86a80
6 changed files with 741 additions and 69 deletions

View file

@ -6,6 +6,11 @@ The first build is intentionally local-first: a CLI, an API, and a small web UI
share the same deck spec, style packs, and PPTX renderer. The same core contract share the same deck spec, style packs, and PPTX renderer. The same core contract
can later run behind `dev.slides.scottfelten.com` and `slides.scottfelten.com`. can later run behind `dev.slides.scottfelten.com` and `slides.scottfelten.com`.
The web UI is source-first: paste notes, drop a file, choose a file, or paste an
image into the source area. The MVP extracts text from Markdown/text, PDF, and
PPTX files. Image upload starts a deck from image metadata today; vision/OCR is
reserved for the Model Service Gateway integration.
## MVP Contract ## MVP Contract
```text ```text
@ -47,6 +52,19 @@ Run the web shell:
npm run dev:web npm run dev:web
``` ```
Then open:
```text
http://127.0.0.1:5185
```
Supported starter sources:
- `.md`, `.markdown`, `.txt`
- `.pdf`
- `.pptx`
- image files or pasted clipboard images
## AgentPlane Notes ## AgentPlane Notes
- Implementation truth should live in Forgejo. - Implementation truth should live in Forgejo.
@ -55,4 +73,3 @@ npm run dev:web
- Google Slides generation should reuse the existing AgentPlane guidance: - Google Slides generation should reuse the existing AgentPlane guidance:
copy templates, inventory native containers, fill existing object IDs, and copy templates, inventory native containers, fill existing object IDs, and
render thumbnails/contact sheets before handoff. render thumbnails/contact sheets before handoff.

View file

@ -12,14 +12,19 @@
"@slide-factory/render-pptx": "0.1.0", "@slide-factory/render-pptx": "0.1.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"fast-xml-parser": "^4.5.0",
"jszip": "^3.10.1",
"multer": "^2.1.1",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"pdf-parse": "^1.1.1",
"tsx": "^4.16.2", "tsx": "^4.16.2",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/pdf-parse": "^1.1.4",
"typescript": "^5.5.4" "typescript": "^5.5.4"
} }
} }

View file

@ -1,11 +1,16 @@
import { access, mkdir, writeFile } from "node:fs/promises"; import { access, mkdir, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { inflateSync } from "node:zlib";
import cors from "cors"; import cors from "cors";
import express from "express"; import express from "express";
import { XMLParser } from "fast-xml-parser";
import JSZip from "jszip";
import multer from "multer";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import pdfParse from "pdf-parse";
import { z } from "zod"; import { z } from "zod";
import { loadStylePack, planDeckFromSource } from "@slide-factory/core"; import { loadStylePack, planDeckFromSource, SourceDocument } from "@slide-factory/core";
import { renderPptx } from "@slide-factory/render-pptx"; import { renderPptx } from "@slide-factory/render-pptx";
const CreateDeckRequestSchema = z.object({ const CreateDeckRequestSchema = z.object({
@ -22,6 +27,13 @@ const CreateDeckRequestSchema = z.object({
const app = express(); const app = express();
const port = Number(process.env.SLIDE_FACTORY_PORT || 3025); const port = Number(process.env.SLIDE_FACTORY_PORT || 3025);
const outputDir = path.resolve(process.env.SLIDE_FACTORY_OUTPUT_DIR || "outputs"); const outputDir = path.resolve(process.env.SLIDE_FACTORY_OUTPUT_DIR || "outputs");
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 30 * 1024 * 1024,
files: 1
}
});
app.use(cors()); app.use(cors());
app.use(express.json({ limit: "5mb" })); app.use(express.json({ limit: "5mb" }));
@ -34,29 +46,38 @@ app.get("/api/health", (_req, res) => {
app.post("/api/decks", async (req, res, next) => { app.post("/api/decks", async (req, res, next) => {
try { try {
const body = CreateDeckRequestSchema.parse(req.body); const body = CreateDeckRequestSchema.parse(req.body);
const id = nanoid(10); const result = await createDeckArtifacts({
const stylePath = await resolveStylePath(body.style);
const style = await loadStylePack(stylePath);
const deck = planDeckFromSource({
source: body.input, source: body.input,
style,
instructions: body.instructions, instructions: body.instructions,
audience: body.audience audience: body.audience,
styleId: body.style
}); });
const jobDir = path.join(outputDir, id); res.status(201).json(result);
const specPath = path.join(jobDir, "deck.json"); } catch (error) {
const pptxPath = path.join(jobDir, "deck.pptx"); next(error);
await mkdir(jobDir, { recursive: true }); }
await writeFile(specPath, `${JSON.stringify(deck, null, 2)}\n`); });
await renderPptx({ deck, style, outputPath: pptxPath });
app.post("/api/decks/from-source", upload.single("source"), async (req, res, next) => {
try {
const file = req.file;
const pastedText = typeof req.body.content === "string" ? req.body.content : "";
const source = file ? await sourceFromUpload(file) : sourceFromText(pastedText);
const result = await createDeckArtifacts({
source,
instructions: stringField(req.body.instructions),
audience: stringField(req.body.audience) || "executives",
styleId: stringField(req.body.style) || "incorta"
});
res.status(201).json({ res.status(201).json({
id, ...result,
title: deck.title, source: {
slides: deck.slides.length, title: source.title,
specUrl: `/outputs/${id}/deck.json`, type: source.type,
pptxUrl: `/outputs/${id}/deck.pptx` sourceName: source.sourceName
}
}); });
} catch (error) { } catch (error) {
next(error); next(error);
@ -86,6 +107,251 @@ async function resolveStylePath(styleId: string): Promise<string> {
return direct; return direct;
} }
async function createDeckArtifacts(options: {
source: SourceDocument;
instructions?: string;
audience: string;
styleId: string;
}) {
const id = nanoid(10);
const stylePath = await resolveStylePath(options.styleId);
const style = await loadStylePack(stylePath);
const deck = planDeckFromSource({
source: options.source,
style,
instructions: options.instructions,
audience: options.audience
});
const jobDir = path.join(outputDir, id);
const specPath = path.join(jobDir, "deck.json");
const pptxPath = path.join(jobDir, "deck.pptx");
await mkdir(jobDir, { recursive: true });
await writeFile(specPath, `${JSON.stringify(deck, null, 2)}\n`);
await renderPptx({ deck, style, outputPath: pptxPath });
return {
id,
title: deck.title,
slides: deck.slides.length,
specUrl: `/outputs/${id}/deck.json`,
pptxUrl: `/outputs/${id}/deck.pptx`
};
}
function sourceFromText(content: string): SourceDocument {
const trimmed = content.trim();
if (!trimmed) {
throw new Error("Provide source text or upload a file.");
}
return {
type: "markdown",
title: deriveTitle(trimmed),
content: trimmed,
sourceName: "pasted-source.md"
};
}
async function sourceFromUpload(file: Express.Multer.File): Promise<SourceDocument> {
const extension = path.extname(file.originalname).toLowerCase();
const mime = file.mimetype;
if (extension === ".md" || extension === ".markdown") {
const content = file.buffer.toString("utf8");
return {
type: "markdown",
title: deriveTitle(content) || baseName(file.originalname),
content,
sourceName: file.originalname
};
}
if (extension === ".txt" || mime.startsWith("text/")) {
const content = file.buffer.toString("utf8");
return {
type: "text",
title: deriveTitle(content) || baseName(file.originalname),
content,
sourceName: file.originalname
};
}
if (extension === ".pdf" || mime === "application/pdf") {
const content = await extractPdfText(file.buffer);
if (!content) throw new Error("No text could be extracted from the PDF.");
return {
type: "pdf",
title: baseName(file.originalname),
content,
sourceName: file.originalname
};
}
if (extension === ".pptx" || mime.includes("presentation")) {
const content = await extractPptxText(file.buffer);
if (!content.trim()) throw new Error("No text could be extracted from the slide deck.");
return {
type: "pptx",
title: baseName(file.originalname),
content,
sourceName: file.originalname
};
}
if (mime.startsWith("image/")) {
return {
type: "text",
title: baseName(file.originalname),
sourceName: file.originalname,
content: [
`# ${baseName(file.originalname)}`,
"",
"Image source uploaded.",
"",
`File name: ${file.originalname}`,
`Image type: ${mime}`,
`File size: ${Math.round(file.size / 1024)} KB`,
"",
"Vision/OCR is not enabled in this local MVP yet. Use the instructions field to describe the slide goal, audience, and what the image should support."
].join("\n")
};
}
throw new Error(`Unsupported source type: ${file.originalname || mime}`);
}
async function extractPdfText(buffer: Buffer): Promise<string> {
try {
const parsed = await pdfParse(buffer);
const content = parsed.text.trim();
if (content) return content;
} catch {
// Fall through to a lightweight stream extractor for simple PDFs.
}
return extractPdfTextFromStreams(buffer).trim();
}
function extractPdfTextFromStreams(buffer: Buffer): string {
const pdf = buffer.toString("latin1");
const chunks: string[] = [];
const streamPattern = /<<(.*?)>>\s*stream\r?\n([\s\S]*?)\r?\nendstream/g;
let match: RegExpExecArray | null;
while ((match = streamPattern.exec(pdf))) {
const dictionary = match[1] || "";
const raw = Buffer.from(match[2] || "", "latin1");
let stream = raw;
if (dictionary.includes("/FlateDecode")) {
try {
stream = inflateSync(raw);
} catch {
continue;
}
}
const text = extractPdfStrings(stream.toString("latin1")).join(" ");
if (text) chunks.push(text);
}
return chunks.join("\n\n").replace(/\s+/g, " ").trim();
}
function extractPdfStrings(stream: string): string[] {
const values: string[] = [];
const literalPattern = /\((?:\\.|[^\\)])*\)/g;
for (const match of stream.matchAll(literalPattern)) {
const value = decodePdfLiteral(match[0].slice(1, -1));
if (value.trim()) values.push(value.trim());
}
const hexPattern = /<([0-9A-Fa-f\s]{4,})>/g;
for (const match of stream.matchAll(hexPattern)) {
const value = decodePdfHex(match[1] || "");
if (value.trim()) values.push(value.trim());
}
return values;
}
function decodePdfLiteral(value: string): string {
return value
.replace(/\\n/g, "\n")
.replace(/\\r/g, "\r")
.replace(/\\t/g, "\t")
.replace(/\\b/g, "\b")
.replace(/\\f/g, "\f")
.replace(/\\([()\\])/g, "$1")
.replace(/\\([0-7]{1,3})/g, (_match, octal: string) => String.fromCharCode(Number.parseInt(octal, 8)));
}
function decodePdfHex(value: string): string {
const normalized = value.replace(/\s+/g, "");
const bytes = (normalized.match(/.{1,2}/g) || []).map((byte) => Number.parseInt(byte.padEnd(2, "0"), 16));
if (bytes[0] === 0xfe && bytes[1] === 0xff) {
const chars: string[] = [];
for (let index = 2; index + 1 < bytes.length; index += 2) {
chars.push(String.fromCharCode((bytes[index] << 8) + bytes[index + 1]));
}
return chars.join("");
}
return Buffer.from(bytes).toString("latin1");
}
async function extractPptxText(buffer: Buffer): Promise<string> {
const zip = await JSZip.loadAsync(buffer);
const parser = new XMLParser({
ignoreAttributes: true,
textNodeName: "#text"
});
const slideFiles = Object.keys(zip.files)
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
.sort((a, b) => slideNumber(a) - slideNumber(b));
const slides: string[] = [];
for (const fileName of slideFiles) {
const xml = await zip.file(fileName)?.async("string");
if (!xml) continue;
const parsed = parser.parse(xml);
const text = collectText(parsed)
.map((value) => value.trim())
.filter(Boolean)
.join(" ");
if (text) {
slides.push(`## Slide ${slideNumber(fileName)}\n\n${text}`);
}
}
return slides.join("\n\n");
}
function collectText(value: unknown): string[] {
if (typeof value === "string") return [value];
if (Array.isArray(value)) return value.flatMap(collectText);
if (!value || typeof value !== "object") return [];
const record = value as Record<string, unknown>;
return Object.entries(record).flatMap(([key, child]) => {
if (key === "a:t" || key === "#text") return collectText(child);
return collectText(child);
});
}
function slideNumber(fileName: string): number {
return Number(/slide(\d+)\.xml$/.exec(fileName)?.[1] || "0");
}
function deriveTitle(content: string): string | undefined {
return content
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.startsWith("# "))
?.replace(/^#\s+/, "")
.trim();
}
function baseName(fileName: string): string {
return path.basename(fileName, path.extname(fileName)).replace(/[-_]+/g, " ");
}
function stringField(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
async function exists(targetPath: string): Promise<boolean> { async function exists(targetPath: string): Promise<boolean> {
try { try {
await access(targetPath); await access(targetPath);

View file

@ -1,6 +1,6 @@
import React, { useState } from "react"; import React, { DragEvent, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { Download, FileText, WandSparkles } from "lucide-react"; import { Download, FileText, Image as ImageIcon, Loader2, Paperclip, Upload, WandSparkles, X } from "lucide-react";
import "./styles.css"; import "./styles.css";
type DeckResult = { type DeckResult = {
@ -9,29 +9,71 @@ type DeckResult = {
slides: number; slides: number;
specUrl: string; specUrl: string;
pptxUrl: string; pptxUrl: string;
source?: {
title?: string;
type?: string;
sourceName?: string;
};
}; };
const initialContent = `# Customer Discovery Notes
Paste notes, transcript excerpts, or brief content here.
- Confirm the top decisions.
- Build an executive narrative.
- Render a PPTX for review.`;
function App() { function App() {
const [content, setContent] = useState(`# Customer Discovery Notes\n\nPaste notes, transcript excerpts, or brief content here.\n\n- Confirm the top decisions.\n- Build an executive narrative.\n- Render a PPTX for review.`); const [content, setContent] = useState(initialContent);
const [instructions, setInstructions] = useState("Create a concise executive deck."); const [instructions, setInstructions] = useState("Create a concise executive deck.");
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const [result, setResult] = useState<DeckResult | null>(null); const [result, setResult] = useState<DeckResult | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const sourceLabel = useMemo(() => {
if (file) return `${file.name} · ${formatBytes(file.size)}`;
const chars = content.trim().length;
return chars ? `${chars.toLocaleString()} characters pasted` : "No source selected";
}, [content, file]);
function setSourceFile(nextFile: File | null) {
if (previewUrl) URL.revokeObjectURL(previewUrl);
setFile(nextFile);
setResult(null);
setError(null);
if (nextFile?.type.startsWith("image/")) {
setPreviewUrl(URL.createObjectURL(nextFile));
} else {
setPreviewUrl(null);
}
}
function onDrop(event: DragEvent<HTMLLabelElement>) {
event.preventDefault();
setDragging(false);
const dropped = event.dataTransfer.files?.[0];
if (dropped) setSourceFile(dropped);
}
async function onPaste(event: React.ClipboardEvent<HTMLTextAreaElement>) {
const image = Array.from(event.clipboardData.files).find((item) => item.type.startsWith("image/"));
if (image) {
event.preventDefault();
setSourceFile(image);
}
}
async function createDeck() { async function createDeck() {
setBusy(true); setBusy(true);
setError(null); setError(null);
setResult(null); setResult(null);
try { try {
const response = await fetch("/api/decks", { const response = file ? await createDeckFromFile(file) : await createDeckFromText();
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
style: "incorta",
instructions,
input: { type: "markdown", content }
})
});
const json = await response.json(); const json = await response.json();
if (!response.ok) throw new Error(json.error || "Deck generation failed"); if (!response.ok) throw new Error(json.error || "Deck generation failed");
setResult(json); setResult(json);
@ -42,42 +84,114 @@ function App() {
} }
} }
function createDeckFromFile(sourceFile: File) {
const formData = new FormData();
formData.set("source", sourceFile);
formData.set("style", "incorta");
formData.set("audience", "executives");
formData.set("instructions", instructions);
return fetch("/api/decks/from-source", {
method: "POST",
body: formData
});
}
function createDeckFromText() {
return fetch("/api/decks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
style: "incorta",
instructions,
input: { type: "markdown", content }
})
});
}
return ( return (
<main className="app-shell"> <main className="app-shell">
<section className="workspace"> <section className="workspace">
<header className="topbar"> <header className="topbar">
<div> <div>
<p className="eyebrow">Slide Factory</p> <p className="eyebrow">Slide Factory</p>
<h1>Deck builder</h1> <h1>Source to deck</h1>
</div> </div>
<button onClick={createDeck} disabled={busy}> <button className="primary-action" onClick={createDeck} disabled={busy}>
<WandSparkles size={18} /> {busy ? <Loader2 className="spin" size={18} /> : <WandSparkles size={18} />}
{busy ? "Building" : "Build"} {busy ? "Building" : "Build deck"}
</button> </button>
</header> </header>
<div className="grid"> <div className="main-grid">
<section className="source-pane">
<label
className={`drop-zone ${dragging ? "is-dragging" : ""}`}
onDragOver={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
>
<input
ref={fileInputRef}
type="file"
accept=".md,.markdown,.txt,.pdf,.pptx,image/*"
onChange={(event) => setSourceFile(event.target.files?.[0] || null)}
/>
<Upload size={20} />
<span>Drop source</span>
<button type="button" className="secondary-action" onClick={() => fileInputRef.current?.click()}>
<Paperclip size={16} />
Choose file
</button>
</label>
<div className="source-status">
<span>{sourceLabel}</span>
{file && (
<button type="button" className="icon-action" onClick={() => setSourceFile(null)} aria-label="Clear file">
<X size={16} />
</button>
)}
</div>
{previewUrl && (
<div className="image-preview">
<img src={previewUrl} alt="" />
</div>
)}
<label className="field"> <label className="field">
<span>Instructions</span> <span>Instructions</span>
<input value={instructions} onChange={(event) => setInstructions(event.target.value)} /> <input value={instructions} onChange={(event) => setInstructions(event.target.value)} />
</label> </label>
<label className="field content"> <label className="field content">
<span>Source Content</span> <span>Paste source</span>
<textarea value={content} onChange={(event) => setContent(event.target.value)} /> <textarea
value={content}
onPaste={onPaste}
onChange={(event) => {
setContent(event.target.value);
if (file) setSourceFile(null);
}}
/>
</label> </label>
</section>
<aside className="result-panel"> <aside className="result-panel">
<div className="result-title"> <div className="result-title">
<FileText size={18} /> {file?.type.startsWith("image/") ? <ImageIcon size={18} /> : <FileText size={18} />}
<span>Output</span> <span>Output</span>
</div> </div>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{!result && !error && <p className="muted">Build a deck to get download links.</p>} {!result && !error && <p className="muted">Ready for a source.</p>}
{result && ( {result && (
<div className="result"> <div className="result">
<strong>{result.title}</strong> <strong>{result.title}</strong>
<span>{result.slides} slides</span> <span>{result.slides} slides</span>
{result.source?.sourceName && <span className="source-chip">{result.source.sourceName}</span>}
<a href={result.pptxUrl}> <a href={result.pptxUrl}>
<Download size={16} /> <Download size={16} />
PPTX PPTX
@ -92,9 +206,14 @@ function App() {
); );
} }
function formatBytes(value: number) {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode> </React.StrictMode>
); );

View file

@ -1,6 +1,6 @@
:root { :root {
color: #172033; color: #172033;
background: #f5f7fb; background: #f4f6fa;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
} }
@ -18,13 +18,17 @@ textarea {
font: inherit; font: inherit;
} }
button {
cursor: pointer;
}
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
padding: 24px; padding: 24px;
} }
.workspace { .workspace {
max-width: 1180px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
@ -33,7 +37,7 @@ textarea {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
margin-bottom: 20px; margin-bottom: 18px;
} }
.eyebrow { .eyebrow {
@ -49,30 +53,87 @@ h1 {
font-size: 28px; font-size: 28px;
} }
button { .primary-action,
.secondary-action,
.icon-action {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
gap: 8px; gap: 8px;
min-height: 40px;
border: 0;
border-radius: 6px; border-radius: 6px;
border: 0;
}
.primary-action {
min-height: 40px;
padding: 0 16px; padding: 0 16px;
color: #fff; color: #fff;
background: #172033; background: #172033;
cursor: pointer;
} }
button:disabled { .primary-action:disabled {
opacity: 0.65; opacity: 0.65;
cursor: wait; cursor: wait;
} }
.grid { .secondary-action {
min-height: 34px;
padding: 0 12px;
color: #172033;
background: #fff;
border: 1px solid #d8dee9;
}
.icon-action {
width: 30px;
height: 30px;
color: #596579;
background: #fff;
border: 1px solid #d8dee9;
}
.main-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 320px; grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px; gap: 16px;
} }
.source-pane {
display: grid;
gap: 12px;
}
.drop-zone {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 10px;
min-height: 68px;
padding: 14px;
border: 1px dashed #aab4c4;
border-radius: 8px;
background: #fff;
}
.drop-zone input {
display: none;
}
.drop-zone.is-dragging {
border-color: #f05a28;
background: #fff7f3;
}
.source-status {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 38px;
padding: 0 4px;
color: #596579;
font-size: 13px;
}
.field { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -87,7 +148,8 @@ button:disabled {
input, input,
textarea, textarea,
.result-panel { .result-panel,
.image-preview {
border: 1px solid #d8dee9; border: 1px solid #d8dee9;
border-radius: 8px; border-radius: 8px;
background: #fff; background: #fff;
@ -98,21 +160,31 @@ input {
padding: 0 12px; padding: 0 12px;
} }
.content {
grid-column: 1;
}
textarea { textarea {
min-height: 520px; min-height: 390px;
padding: 14px; padding: 14px;
resize: vertical; resize: vertical;
line-height: 1.45; line-height: 1.45;
} }
.image-preview {
display: flex;
align-items: center;
justify-content: center;
min-height: 220px;
overflow: hidden;
}
.image-preview img {
display: block;
max-width: 100%;
max-height: 340px;
object-fit: contain;
}
.result-panel { .result-panel {
grid-column: 2;
grid-row: 1 / span 2;
padding: 16px; padding: 16px;
min-height: 420px;
} }
.result-title { .result-title {
@ -135,6 +207,18 @@ textarea {
gap: 10px; gap: 10px;
} }
.source-chip {
display: inline-flex;
width: fit-content;
max-width: 100%;
padding: 4px 8px;
border-radius: 999px;
color: #596579;
background: #f4f6fa;
font-size: 12px;
overflow-wrap: anywhere;
}
.result a { .result a {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -144,13 +228,31 @@ textarea {
text-decoration: none; text-decoration: none;
} }
.spin {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 860px) { @media (max-width: 860px) {
.grid { .app-shell {
padding: 16px;
}
.main-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.result-panel { .drop-zone {
grid-column: 1; grid-template-columns: auto 1fr;
grid-row: auto; }
.drop-zone .secondary-action {
grid-column: 1 / -1;
justify-self: start;
} }
} }

163
package-lock.json generated
View file

@ -30,13 +30,19 @@
"@slide-factory/render-pptx": "0.1.0", "@slide-factory/render-pptx": "0.1.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"fast-xml-parser": "^4.5.0",
"jszip": "^3.10.1",
"multer": "^2.1.1",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"pdf-parse": "^1.1.1",
"tsx": "^4.16.2", "tsx": "^4.16.2",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/pdf-parse": "^1.1.4",
"typescript": "^5.5.4" "typescript": "^5.5.4"
} }
}, },
@ -1264,6 +1270,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/multer": {
"version": "1.4.13",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
"integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.42", "version": "20.19.42",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz",
@ -1274,6 +1290,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/pdf-parse": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz",
"integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -1382,6 +1408,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@ -1472,6 +1504,23 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -1539,6 +1588,35 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -1838,6 +1916,24 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-xml-parser": {
"version": "4.5.6",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz",
"integrity": "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.0.5"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@ -2230,6 +2326,25 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "5.1.11", "version": "5.1.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
@ -2257,6 +2372,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-ensure": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
"license": "MIT"
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.47", "version": "2.0.47",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
@ -2320,6 +2441,22 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdf-parse": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.4.tgz",
"integrity": "sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==",
"license": "MIT",
"dependencies": {
"node-ensure": "^0.0.0"
},
"engines": {
"node": ">=6.8.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2765,6 +2902,14 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@ -2780,6 +2925,18 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@ -2820,6 +2977,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",