slide-factory/apps/web/src/main.tsx
2026-06-09 11:20:08 -07:00

790 lines
25 KiB
TypeScript

import React, { DragEvent, useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import {
Download,
ExternalLink,
FileText,
Image as ImageIcon,
Layers,
LayoutTemplate,
Loader2,
Palette,
Paperclip,
Pencil,
Save,
Upload,
WandSparkles,
X
} from "lucide-react";
import "./styles.css";
type Mode = "template-build" | "deck-build" | "template-edit" | "deck-edit";
type DeckResult = {
id: string;
title: string;
slides: number;
specUrl: string;
pptxUrl: string;
source?: {
title?: string;
type?: string;
sourceName?: string;
};
};
type DesignTemplate = {
id: string;
name: string;
description: string;
sourceName?: string;
colors: string[];
updatedAt: string;
locked?: boolean;
};
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.`;
const starterTemplate: DesignTemplate = {
id: "incorta",
name: "Incorta Starter",
description:
"White executive deck, orange brand accent, restrained blue secondary accents, Aptos typography, compact business density, fixed title/body/two-column/roadmap layouts.",
colors: ["#ffffff", "#f05a28", "#1e6bff", "#172033", "#596579"],
updatedAt: "2026-06-09T00:00:00.000Z",
locked: true
};
const modeTabs: Array<{ id: Mode; label: string; icon: React.ElementType }> = [
{ id: "template-build", label: "Build Template", icon: LayoutTemplate },
{ id: "deck-build", label: "Build Slide / Deck", icon: Layers },
{ id: "template-edit", label: "Edit Template", icon: Palette },
{ id: "deck-edit", label: "Edit Slide / Deck", icon: Pencil }
];
function App() {
const [activeMode, setActiveMode] = useState<Mode>("template-build");
const [templates, setTemplates] = useState<DesignTemplate[]>([starterTemplate]);
const [selectedTemplateId, setSelectedTemplateId] = useState(starterTemplate.id);
const [content, setContent] = useState(initialContent);
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 [templateName, setTemplateName] = useState("New design standard");
const [templateDescription, setTemplateDescription] = useState(
"Executive presentation system with clear hierarchy, reusable slide sections, consistent spacing, and restrained visual emphasis."
);
const [templateFile, setTemplateFile] = useState<File | null>(null);
const [templatePreviewUrl, setTemplatePreviewUrl] = useState<string | null>(null);
const [templateDragging, setTemplateDragging] = useState(false);
const [editTemplateId, setEditTemplateId] = useState(starterTemplate.id);
const [editTemplateName, setEditTemplateName] = useState(starterTemplate.name);
const [editTemplateDescription, setEditTemplateDescription] = useState(starterTemplate.description);
const [result, setResult] = useState<DeckResult | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const templateInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
try {
const stored = window.localStorage.getItem("slideFactory.designTemplates.v1");
if (!stored) return;
const parsed = JSON.parse(stored) as DesignTemplate[];
const customTemplates = parsed.filter((template) => template.id !== starterTemplate.id);
setTemplates([starterTemplate, ...customTemplates]);
} catch {
setTemplates([starterTemplate]);
}
}, []);
useEffect(() => {
const customTemplates = templates.filter((template) => !template.locked);
window.localStorage.setItem("slideFactory.designTemplates.v1", JSON.stringify(customTemplates));
}, [templates]);
const selectedTemplate = useMemo(
() => templates.find((template) => template.id === selectedTemplateId) || starterTemplate,
[selectedTemplateId, templates]
);
const editableTemplate = useMemo(
() => templates.find((template) => template.id === editTemplateId) || starterTemplate,
[editTemplateId, templates]
);
const activeTemplatePreview = useMemo<DesignTemplate>(() => {
if (activeMode === "template-build") {
return {
id: "draft-template",
name: templateName.trim() || "Untitled design standard",
description: templateDescription.trim(),
sourceName: templateFile?.name,
colors: starterTemplate.colors,
updatedAt: new Date().toISOString()
};
}
if (activeMode === "template-edit") {
return {
...editableTemplate,
name: editTemplateName.trim() || editableTemplate.name,
description: editTemplateDescription.trim()
};
}
return selectedTemplate;
}, [
activeMode,
editableTemplate,
editTemplateDescription,
editTemplateName,
selectedTemplate,
templateDescription,
templateFile,
templateName
]);
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]);
const templateSourceLabel = useMemo(() => {
if (templateFile) return `${templateFile.name} · ${formatBytes(templateFile.size)}`;
return "No design source selected";
}, [templateFile]);
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 setTemplateSourceFile(nextFile: File | null) {
if (templatePreviewUrl) URL.revokeObjectURL(templatePreviewUrl);
setTemplateFile(nextFile);
setNotice(null);
setError(null);
if (nextFile?.type.startsWith("image/")) {
setTemplatePreviewUrl(URL.createObjectURL(nextFile));
} else {
setTemplatePreviewUrl(null);
}
}
function onSourceDrop(event: DragEvent<HTMLLabelElement>) {
event.preventDefault();
setDragging(false);
const dropped = event.dataTransfer.files?.[0];
if (dropped) setSourceFile(dropped);
}
function onTemplateDrop(event: DragEvent<HTMLLabelElement>) {
event.preventDefault();
setTemplateDragging(false);
const dropped = event.dataTransfer.files?.[0];
if (dropped) setTemplateSourceFile(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);
}
}
function saveTemplate() {
const name = templateName.trim() || "Untitled design standard";
const template: DesignTemplate = {
id: `template-${Date.now()}`,
name,
description: templateDescription.trim(),
sourceName: templateFile?.name,
colors: starterTemplate.colors,
updatedAt: new Date().toISOString()
};
setTemplates((current) => [starterTemplate, template, ...current.filter((item) => item.id !== starterTemplate.id)]);
setSelectedTemplateId(template.id);
setEditTemplateId(template.id);
setEditTemplateName(template.name);
setEditTemplateDescription(template.description);
setNotice(`${template.name} saved`);
}
function loadEditableTemplate(templateId: string) {
const template = templates.find((item) => item.id === templateId) || starterTemplate;
setEditTemplateId(template.id);
setEditTemplateName(template.name);
setEditTemplateDescription(template.description);
setNotice(null);
setError(null);
}
function updateTemplate() {
if (editableTemplate.locked) {
const copy: DesignTemplate = {
...editableTemplate,
id: `template-${Date.now()}`,
name: `${editTemplateName.trim() || editableTemplate.name} Copy`,
description: editTemplateDescription.trim(),
locked: false,
updatedAt: new Date().toISOString()
};
setTemplates((current) => [starterTemplate, copy, ...current.filter((item) => item.id !== starterTemplate.id)]);
setSelectedTemplateId(copy.id);
setEditTemplateId(copy.id);
setEditTemplateName(copy.name);
setNotice(`${copy.name} saved`);
return;
}
setTemplates((current) =>
current.map((template) =>
template.id === editTemplateId
? {
...template,
name: editTemplateName.trim() || template.name,
description: editTemplateDescription.trim(),
updatedAt: new Date().toISOString()
}
: template
)
);
setNotice(`${editTemplateName.trim() || editableTemplate.name} updated`);
}
function removeEditableTemplate() {
if (editableTemplate.locked) return;
setTemplates((current) => current.filter((template) => template.id !== editableTemplate.id));
setSelectedTemplateId(starterTemplate.id);
loadEditableTemplate(starterTemplate.id);
setNotice(`${editableTemplate.name} removed`);
}
async function createDeck() {
setBusy(true);
setError(null);
setNotice(null);
setResult(null);
try {
const response = file ? await createDeckFromFile(file) : await createDeckFromText();
const json = await response.json();
if (!response.ok) throw new Error(json.error || "Deck generation failed");
setResult(json);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setBusy(false);
}
}
function designAwareInstructions() {
return [
instructions,
"",
`Selected design template: ${selectedTemplate.name}.`,
selectedTemplate.description
]
.filter(Boolean)
.join("\n");
}
function createDeckFromFile(sourceFile: File) {
const formData = new FormData();
formData.set("source", sourceFile);
formData.set("style", "incorta");
formData.set("audience", "executives");
formData.set("instructions", designAwareInstructions());
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: designAwareInstructions(),
input: { type: "markdown", content }
})
});
}
const title = modeTabs.find((mode) => mode.id === activeMode)?.label || "Slide Factory";
return (
<main className="app-shell">
<section className="workspace">
<header className="topbar">
<div>
<p className="eyebrow">Slide Factory</p>
<h1>{title}</h1>
</div>
<div className="topbar-actions">
<a className="secondary-action" href="/google-slides-workflow.html">
<ExternalLink size={16} />
Google Slides workflow
</a>
{activeMode === "template-build" && (
<button className="primary-action" onClick={saveTemplate}>
<Save size={18} />
Save template
</button>
)}
{activeMode === "deck-build" && (
<button className="primary-action" onClick={createDeck} disabled={busy}>
{busy ? <Loader2 className="spin" size={18} /> : <WandSparkles size={18} />}
{busy ? "Building" : "Build deck"}
</button>
)}
{activeMode === "template-edit" && (
<button className="primary-action" onClick={updateTemplate}>
<Save size={18} />
Save edits
</button>
)}
{activeMode === "deck-edit" && (
<button className="primary-action" onClick={createDeck} disabled={busy}>
{busy ? <Loader2 className="spin" size={18} /> : <WandSparkles size={18} />}
{busy ? "Rebuilding" : "Build revision"}
</button>
)}
</div>
</header>
<nav className="mode-tabs" aria-label="Slide Factory modes">
{modeTabs.map((mode) => {
const Icon = mode.icon;
return (
<button
key={mode.id}
type="button"
className={`mode-tab ${activeMode === mode.id ? "is-active" : ""}`}
onClick={() => setActiveMode(mode.id)}
>
<Icon size={16} />
{mode.label}
</button>
);
})}
</nav>
<div className="main-grid">
<section className="source-pane">
{activeMode === "template-build" && (
<TemplateBuildPane
dragging={templateDragging}
inputRef={templateInputRef}
name={templateName}
previewUrl={templatePreviewUrl}
sourceLabel={templateSourceLabel}
description={templateDescription}
onDrop={onTemplateDrop}
onDragging={setTemplateDragging}
onFile={setTemplateSourceFile}
onName={setTemplateName}
onDescription={setTemplateDescription}
onSave={saveTemplate}
/>
)}
{activeMode === "deck-build" && (
<DeckBuildPane
content={content}
dragging={dragging}
file={file}
fileInputRef={fileInputRef}
instructions={instructions}
previewUrl={previewUrl}
selectedTemplateId={selectedTemplateId}
sourceLabel={sourceLabel}
templates={templates}
onContent={setContent}
onDragging={setDragging}
onDrop={onSourceDrop}
onFile={setSourceFile}
onInstructions={setInstructions}
onPaste={onPaste}
onTemplate={setSelectedTemplateId}
/>
)}
{activeMode === "template-edit" && (
<TemplateEditPane
description={editTemplateDescription}
editableTemplate={editableTemplate}
name={editTemplateName}
templateId={editTemplateId}
templates={templates}
onDescription={setEditTemplateDescription}
onName={setEditTemplateName}
onRemove={removeEditableTemplate}
onTemplate={loadEditableTemplate}
/>
)}
{activeMode === "deck-edit" && (
<DeckEditPane
content={content}
dragging={dragging}
file={file}
fileInputRef={fileInputRef}
instructions={instructions}
previewUrl={previewUrl}
selectedTemplateId={selectedTemplateId}
sourceLabel={sourceLabel}
templates={templates}
onContent={setContent}
onDragging={setDragging}
onDrop={onSourceDrop}
onFile={setSourceFile}
onInstructions={setInstructions}
onPaste={onPaste}
onTemplate={setSelectedTemplateId}
/>
)}
</section>
<OutputPanel
activeMode={activeMode}
error={error}
notice={notice}
result={result}
template={activeTemplatePreview}
templates={templates}
/>
</div>
</section>
</main>
);
}
function TemplateBuildPane(props: {
dragging: boolean;
inputRef: React.RefObject<HTMLInputElement>;
name: string;
previewUrl: string | null;
sourceLabel: string;
description: string;
onDrop: (event: DragEvent<HTMLLabelElement>) => void;
onDragging: (dragging: boolean) => void;
onFile: (file: File | null) => void;
onName: (value: string) => void;
onDescription: (value: string) => void;
onSave: () => void;
}) {
return (
<>
<label
className={`drop-zone ${props.dragging ? "is-dragging" : ""}`}
onDragOver={(event) => {
event.preventDefault();
props.onDragging(true);
}}
onDragLeave={() => props.onDragging(false)}
onDrop={props.onDrop}
>
<input
ref={props.inputRef}
type="file"
accept=".ppt,.pptx,.pdf,image/*"
onChange={(event) => props.onFile(event.target.files?.[0] || null)}
/>
<Upload size={20} />
<span>Drop design source</span>
<button type="button" className="secondary-action" onClick={() => props.inputRef.current?.click()}>
<Paperclip size={16} />
Choose file
</button>
</label>
<div className="source-status">
<span>{props.sourceLabel}</span>
{props.previewUrl && (
<button type="button" className="icon-action" onClick={() => props.onFile(null)} aria-label="Clear design source">
<X size={16} />
</button>
)}
</div>
{props.previewUrl && (
<div className="image-preview">
<img src={props.previewUrl} alt="" />
</div>
)}
<label className="field">
<span>Template name</span>
<input value={props.name} onChange={(event) => props.onName(event.target.value)} />
</label>
<label className="field content compact">
<span>Design description</span>
<textarea value={props.description} onChange={(event) => props.onDescription(event.target.value)} />
</label>
<button type="button" className="secondary-action wide-action" onClick={props.onSave}>
<Save size={16} />
Save template
</button>
</>
);
}
function DeckBuildPane(props: DeckPaneProps) {
return (
<>
<TemplateSelect templates={props.templates} value={props.selectedTemplateId} onChange={props.onTemplate} />
<SourceInput {...props} dropLabel="Drop source" textLabel="Paste source" />
</>
);
}
function DeckEditPane(props: DeckPaneProps) {
return (
<>
<TemplateSelect templates={props.templates} value={props.selectedTemplateId} onChange={props.onTemplate} />
<SourceInput {...props} dropLabel="Drop slide / deck" textLabel="Revision notes" />
</>
);
}
type DeckPaneProps = {
content: string;
dragging: boolean;
file: File | null;
fileInputRef: React.RefObject<HTMLInputElement>;
instructions: string;
previewUrl: string | null;
selectedTemplateId: string;
sourceLabel: string;
templates: DesignTemplate[];
onContent: (value: string) => void;
onDragging: (dragging: boolean) => void;
onDrop: (event: DragEvent<HTMLLabelElement>) => void;
onFile: (file: File | null) => void;
onInstructions: (value: string) => void;
onPaste: (event: React.ClipboardEvent<HTMLTextAreaElement>) => void;
onTemplate: (id: string) => void;
};
function TemplateSelect(props: { templates: DesignTemplate[]; value: string; onChange: (id: string) => void }) {
const active = props.templates.find((template) => template.id === props.value) || starterTemplate;
return (
<div className="template-select">
<label className="field">
<span>Design template</span>
<select value={props.value} onChange={(event) => props.onChange(event.target.value)}>
{props.templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</label>
<Swatches colors={active.colors} />
</div>
);
}
function SourceInput(props: DeckPaneProps & { dropLabel: string; textLabel: string }) {
return (
<>
<label
className={`drop-zone ${props.dragging ? "is-dragging" : ""}`}
onDragOver={(event) => {
event.preventDefault();
props.onDragging(true);
}}
onDragLeave={() => props.onDragging(false)}
onDrop={props.onDrop}
>
<input
ref={props.fileInputRef}
type="file"
accept=".md,.markdown,.txt,.pdf,.ppt,.pptx,image/*"
onChange={(event) => props.onFile(event.target.files?.[0] || null)}
/>
<Upload size={20} />
<span>{props.dropLabel}</span>
<button type="button" className="secondary-action" onClick={() => props.fileInputRef.current?.click()}>
<Paperclip size={16} />
Choose file
</button>
</label>
<div className="source-status">
<span>{props.sourceLabel}</span>
{props.file && (
<button type="button" className="icon-action" onClick={() => props.onFile(null)} aria-label="Clear file">
<X size={16} />
</button>
)}
</div>
{props.previewUrl && (
<div className="image-preview">
<img src={props.previewUrl} alt="" />
</div>
)}
<label className="field">
<span>Instructions</span>
<input value={props.instructions} onChange={(event) => props.onInstructions(event.target.value)} />
</label>
<label className="field content">
<span>{props.textLabel}</span>
<textarea
value={props.content}
onPaste={props.onPaste}
onChange={(event) => {
props.onContent(event.target.value);
if (props.file) props.onFile(null);
}}
/>
</label>
</>
);
}
function TemplateEditPane(props: {
description: string;
editableTemplate: DesignTemplate;
name: string;
templateId: string;
templates: DesignTemplate[];
onDescription: (value: string) => void;
onName: (value: string) => void;
onRemove: () => void;
onTemplate: (id: string) => void;
}) {
return (
<>
<div className="template-select">
<label className="field">
<span>Template</span>
<select value={props.templateId} onChange={(event) => props.onTemplate(event.target.value)}>
{props.templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</label>
<Swatches colors={props.editableTemplate.colors} />
</div>
<label className="field">
<span>Template name</span>
<input value={props.name} onChange={(event) => props.onName(event.target.value)} />
</label>
<label className="field content compact">
<span>Design description</span>
<textarea value={props.description} onChange={(event) => props.onDescription(event.target.value)} />
</label>
<div className="button-row">
<button type="button" className="secondary-action" onClick={props.onRemove} disabled={props.editableTemplate.locked}>
<X size={16} />
Remove
</button>
</div>
</>
);
}
function OutputPanel(props: {
activeMode: Mode;
error: string | null;
notice: string | null;
result: DeckResult | null;
template: DesignTemplate;
templates: DesignTemplate[];
}) {
const templateMode = props.activeMode === "template-build" || props.activeMode === "template-edit";
return (
<aside className="result-panel">
<div className="result-title">
{templateMode ? <LayoutTemplate size={18} /> : <FileText size={18} />}
<span>{templateMode ? "Template" : "Output"}</span>
</div>
{props.error && <p className="error">{props.error}</p>}
{props.notice && <p className="notice">{props.notice}</p>}
{templateMode ? (
<div className="result">
<strong>{props.template.name}</strong>
<Swatches colors={props.template.colors} />
{props.template.description && <p className="template-summary">{props.template.description}</p>}
<span>{props.templates.length} templates</span>
{props.template.sourceName && <span className="source-chip">{props.template.sourceName}</span>}
</div>
) : (
<>
{!props.result && !props.error && <p className="muted">Ready for a source.</p>}
{props.result && (
<div className="result">
<strong>{props.result.title}</strong>
<span>{props.result.slides} slides</span>
{props.result.source?.sourceName && <span className="source-chip">{props.result.source.sourceName}</span>}
<a href={props.result.pptxUrl}>
<Download size={16} />
PPTX
</a>
<a href={props.result.specUrl}>DeckSpec JSON</a>
</div>
)}
</>
)}
</aside>
);
}
function Swatches(props: { colors: string[] }) {
return (
<div className="swatches" aria-label="Template colors">
{props.colors.map((color) => (
<span key={color} className="swatch" style={{ background: color }} />
))}
</div>
);
}
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(
<React.StrictMode>
<App />
</React.StrictMode>
);