Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e913fecd4c | ||
|
|
6dc8e47eb6 | ||
|
|
9855b92605 |
3 changed files with 1399 additions and 86 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { access, mkdir, writeFile } from "node:fs/promises";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { inflateSync } from "node:zlib";
|
||||
|
|
@ -11,13 +11,28 @@ import multer from "multer";
|
|||
import { nanoid } from "nanoid";
|
||||
import pdfParse from "pdf-parse";
|
||||
import { z } from "zod";
|
||||
import { loadStylePack, planDeckFromSource, SourceDocument } from "@slide-factory/core";
|
||||
import { loadStylePack, planDeckFromSource, SourceDocument, StylePack } from "@slide-factory/core";
|
||||
import { renderPptx } from "@slide-factory/render-pptx";
|
||||
|
||||
const DesignTemplateSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
colors: z.array(z.string()).optional(),
|
||||
layoutRules: z.array(z.string()).optional(),
|
||||
componentRules: z.array(z.string()).optional(),
|
||||
slideFamilies: z.array(z.string()).optional(),
|
||||
prompt: z.string().optional(),
|
||||
analysisSource: z.string().optional(),
|
||||
sourceName: z.string().optional()
|
||||
});
|
||||
|
||||
const CreateDeckRequestSchema = z.object({
|
||||
style: z.string().default("incorta"),
|
||||
instructions: z.string().optional(),
|
||||
audience: z.string().default("executives"),
|
||||
designTemplate: DesignTemplateSchema.optional(),
|
||||
input: z.object({
|
||||
type: z.enum(["text", "markdown"]).default("markdown"),
|
||||
title: z.string().optional(),
|
||||
|
|
@ -25,6 +40,42 @@ const CreateDeckRequestSchema = z.object({
|
|||
})
|
||||
});
|
||||
|
||||
const TemplateAnalyzeRequestSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional()
|
||||
});
|
||||
|
||||
type DesignTemplateInput = z.infer<typeof DesignTemplateSchema>;
|
||||
|
||||
type SourceFacts = {
|
||||
sourceName?: string;
|
||||
mimeType?: string;
|
||||
extension?: string;
|
||||
sizeKb?: number;
|
||||
imageWidth?: number;
|
||||
imageHeight?: number;
|
||||
extractedText?: string;
|
||||
};
|
||||
|
||||
type TemplateAnalysis = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
summary: string;
|
||||
colors: string[];
|
||||
layoutRules: string[];
|
||||
componentRules: string[];
|
||||
slideFamilies: string[];
|
||||
prompt: string;
|
||||
sourceName?: string;
|
||||
updatedAt: string;
|
||||
analysisSource: "model-service-gateway" | "heuristic";
|
||||
analysisStatus: "ai-analyzed" | "fallback";
|
||||
analysisNote: string;
|
||||
gatewayRequestId?: string;
|
||||
sourceFacts?: SourceFacts;
|
||||
};
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.SLIDE_FACTORY_PORT || 3025);
|
||||
const outputDir = path.resolve(process.env.SLIDE_FACTORY_OUTPUT_DIR || "outputs");
|
||||
|
|
@ -52,7 +103,8 @@ app.post("/api/decks", async (req, res, next) => {
|
|||
source: body.input,
|
||||
instructions: body.instructions,
|
||||
audience: body.audience,
|
||||
styleId: body.style
|
||||
styleId: body.style,
|
||||
designTemplate: body.designTemplate
|
||||
});
|
||||
|
||||
res.status(201).json(result);
|
||||
|
|
@ -68,9 +120,13 @@ app.post("/api/decks/from-source", upload.single("source"), async (req, res, nex
|
|||
const source = file ? await sourceFromUpload(file) : sourceFromText(pastedText);
|
||||
const result = await createDeckArtifacts({
|
||||
source,
|
||||
instructions: stringField(req.body.instructions),
|
||||
instructions: combineInstructions(
|
||||
stringField(req.body.instructions),
|
||||
file && pastedText.trim() ? `Attached slide/deck update instructions:\n${pastedText.trim()}` : undefined
|
||||
),
|
||||
audience: stringField(req.body.audience) || "executives",
|
||||
styleId: stringField(req.body.style) || "incorta"
|
||||
styleId: stringField(req.body.style) || "incorta",
|
||||
designTemplate: parseDesignTemplateField(req.body.designTemplate)
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
|
|
@ -86,6 +142,21 @@ app.post("/api/decks/from-source", upload.single("source"), async (req, res, nex
|
|||
}
|
||||
});
|
||||
|
||||
app.post("/api/templates/analyze", upload.single("source"), async (req, res, next) => {
|
||||
try {
|
||||
const body = TemplateAnalyzeRequestSchema.parse(req.body);
|
||||
const template = await analyzeTemplate({
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
file: req.file || undefined
|
||||
});
|
||||
|
||||
res.status(201).json({ template });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
if (existsSync(webDistDir)) {
|
||||
app.use(express.static(webDistDir));
|
||||
app.get("*", (req, res, next) => {
|
||||
|
|
@ -128,10 +199,12 @@ async function createDeckArtifacts(options: {
|
|||
instructions?: string;
|
||||
audience: string;
|
||||
styleId: string;
|
||||
designTemplate?: DesignTemplateInput;
|
||||
}) {
|
||||
const id = nanoid(10);
|
||||
const stylePath = await resolveStylePath(options.styleId);
|
||||
const style = await loadStylePack(stylePath);
|
||||
const baseStyle = await loadStylePack(stylePath);
|
||||
const style = applyDesignTemplateToStyle(baseStyle, options.designTemplate);
|
||||
const deck = planDeckFromSource({
|
||||
source: options.source,
|
||||
style,
|
||||
|
|
@ -155,6 +228,373 @@ async function createDeckArtifacts(options: {
|
|||
};
|
||||
}
|
||||
|
||||
async function analyzeTemplate(options: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
file?: Express.Multer.File;
|
||||
}): Promise<TemplateAnalysis> {
|
||||
const name = options.name?.trim() || "Untitled design standard";
|
||||
const description = options.description?.trim() || "";
|
||||
const sourceFacts = await buildSourceFacts(options.file);
|
||||
const gateway = await analyzeTemplateWithGateway({ name, description, sourceFacts });
|
||||
if (gateway) return gateway;
|
||||
return heuristicTemplateAnalysis({ name, description, sourceFacts });
|
||||
}
|
||||
|
||||
async function analyzeTemplateWithGateway(options: {
|
||||
name: string;
|
||||
description: string;
|
||||
sourceFacts: SourceFacts;
|
||||
}): Promise<TemplateAnalysis | null> {
|
||||
const enabled = process.env.MODEL_SERVICE_GATEWAY_ENABLED === "true";
|
||||
const baseUrl = process.env.MODEL_SERVICE_BASE_URL;
|
||||
const appId = process.env.MODEL_SERVICE_APP_ID || "slide-factory";
|
||||
const model = process.env.MODEL_SERVICE_GATEWAY_MODEL || "gpt-4o-mini";
|
||||
if (!enabled || !baseUrl) return null;
|
||||
|
||||
const token = await readGatewayToken();
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/api/gateway/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
appId,
|
||||
model,
|
||||
temperature: 0.2,
|
||||
maxOutputTokens: 1800,
|
||||
attribution: {
|
||||
feature: "slide-template-analysis",
|
||||
environment: process.env.NODE_ENV || process.env.ENV || "local"
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
"You convert presentation screenshots, slide decks, and design notes into reusable slide design standards.",
|
||||
"Return strict JSON only. Do not include markdown fences.",
|
||||
"The current gateway input is text-only, so base visual claims on extracted source facts, filenames, and user-provided notes. Do not invent pixel-specific observations."
|
||||
].join(" ")
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: JSON.stringify(
|
||||
{
|
||||
task: "Create a reusable design template for consistent slide/deck generation.",
|
||||
templateName: options.name,
|
||||
userDescription: options.description,
|
||||
sourceFacts: options.sourceFacts,
|
||||
requiredJsonShape: {
|
||||
summary: "one short paragraph",
|
||||
colors: ["#112233", "#ffffff", "#..."],
|
||||
layoutRules: ["rule", "rule"],
|
||||
componentRules: ["rule", "rule"],
|
||||
slideFamilies: ["title", "two-column", "metric", "roadmap"],
|
||||
prompt: "detailed reusable design prompt"
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
]
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000)
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = (await response.json()) as { id?: string; content?: string };
|
||||
const parsed = parseJsonFromText(data.content || "");
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const record = parsed as Record<string, unknown>;
|
||||
return normalizeTemplateAnalysis({
|
||||
id: `template-${nanoid(10)}`,
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
sourceFacts: options.sourceFacts,
|
||||
source: "model-service-gateway",
|
||||
status: "ai-analyzed",
|
||||
note:
|
||||
"Analyzed through Model Service Gateway from your description and extracted source metadata. Raw image pixels are not yet sent as multimodal vision input.",
|
||||
gatewayRequestId: data.id,
|
||||
record
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function heuristicTemplateAnalysis(options: {
|
||||
name: string;
|
||||
description: string;
|
||||
sourceFacts: SourceFacts;
|
||||
}): TemplateAnalysis {
|
||||
const record = {
|
||||
summary: options.description || "Reusable executive presentation standard with consistent hierarchy, spacing, and visual rhythm.",
|
||||
colors: inferColors(options.name, options.description, options.sourceFacts),
|
||||
layoutRules: inferLayoutRules(options.description),
|
||||
componentRules: [
|
||||
"Use repeated title, content, evidence, and takeaway regions across slides.",
|
||||
"Keep icons minimal, line-based, and functional.",
|
||||
"Use one accent system for dividers, section labels, status markers, and callouts."
|
||||
],
|
||||
slideFamilies: ["title", "single-column", "two-column", "three-column", "four-column", "metric-callout", "roadmap"],
|
||||
prompt: [
|
||||
`Create slides in the ${options.name} design system.`,
|
||||
options.description || "Use consistent spacing, hierarchy, and reusable slide sections.",
|
||||
"Preserve a clear executive narrative, use restrained decoration, and keep visual choices repeatable across a deck."
|
||||
].join(" ")
|
||||
};
|
||||
|
||||
return normalizeTemplateAnalysis({
|
||||
id: `template-${nanoid(10)}`,
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
sourceFacts: options.sourceFacts,
|
||||
source: "heuristic",
|
||||
status: "fallback",
|
||||
note: "Model Service Gateway was not configured or did not return a usable response, so Slide Factory saved a deterministic local template draft.",
|
||||
record
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTemplateAnalysis(options: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
sourceFacts: SourceFacts;
|
||||
source: "model-service-gateway" | "heuristic";
|
||||
status: "ai-analyzed" | "fallback";
|
||||
note: string;
|
||||
gatewayRequestId?: string;
|
||||
record: Record<string, unknown>;
|
||||
}): TemplateAnalysis {
|
||||
const summary = stringValue(options.record.summary) || options.description || "Reusable slide design standard.";
|
||||
const colors = stringArray(options.record.colors).map(normalizeHexColor).filter(Boolean).slice(0, 6);
|
||||
return {
|
||||
id: options.id,
|
||||
name: options.name,
|
||||
description: options.description || summary,
|
||||
summary,
|
||||
colors: colors.length ? colors : inferColors(options.name, options.description, options.sourceFacts),
|
||||
layoutRules: stringArray(options.record.layoutRules).slice(0, 8),
|
||||
componentRules: stringArray(options.record.componentRules).slice(0, 8),
|
||||
slideFamilies: stringArray(options.record.slideFamilies).slice(0, 10),
|
||||
prompt: stringValue(options.record.prompt) || `${options.description}\n\n${summary}`.trim(),
|
||||
sourceName: options.sourceFacts.sourceName,
|
||||
updatedAt: new Date().toISOString(),
|
||||
analysisSource: options.source,
|
||||
analysisStatus: options.status,
|
||||
analysisNote: options.note,
|
||||
gatewayRequestId: options.gatewayRequestId,
|
||||
sourceFacts: options.sourceFacts
|
||||
};
|
||||
}
|
||||
|
||||
async function buildSourceFacts(file?: Express.Multer.File): Promise<SourceFacts> {
|
||||
if (!file) return {};
|
||||
const extension = path.extname(file.originalname).toLowerCase();
|
||||
const facts: SourceFacts = {
|
||||
sourceName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
extension,
|
||||
sizeKb: Math.round(file.size / 1024)
|
||||
};
|
||||
|
||||
const dimensions = imageDimensions(file.buffer, file.mimetype);
|
||||
if (dimensions) {
|
||||
facts.imageWidth = dimensions.width;
|
||||
facts.imageHeight = dimensions.height;
|
||||
}
|
||||
|
||||
try {
|
||||
if (extension === ".pptx" || file.mimetype.includes("presentation")) {
|
||||
facts.extractedText = truncateForPrompt(await extractPptxText(file.buffer));
|
||||
} else if (extension === ".pdf" || file.mimetype === "application/pdf") {
|
||||
facts.extractedText = truncateForPrompt(await extractPdfText(file.buffer));
|
||||
} else if (file.mimetype.startsWith("text/")) {
|
||||
facts.extractedText = truncateForPrompt(file.buffer.toString("utf8"));
|
||||
}
|
||||
} catch {
|
||||
// Source facts are helpful but should not block template creation.
|
||||
}
|
||||
|
||||
return facts;
|
||||
}
|
||||
|
||||
async function readGatewayToken(): Promise<string | null> {
|
||||
if (process.env.MODEL_SERVICE_GATEWAY_TOKEN) return process.env.MODEL_SERVICE_GATEWAY_TOKEN;
|
||||
const tokenFile = process.env.MODEL_SERVICE_GATEWAY_TOKEN_FILE;
|
||||
if (!tokenFile) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(tokenFile, "utf8")) as { token?: unknown };
|
||||
return typeof parsed.token === "string" && parsed.token.trim() ? parsed.token.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDesignTemplateField(value: unknown): DesignTemplateInput | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const parsed = typeof value === "string" ? JSON.parse(value) : value;
|
||||
return DesignTemplateSchema.parse(parsed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonFromText(value: string): unknown {
|
||||
const trimmed = value.trim().replace(/^```json\s*/i, "").replace(/^```\s*/i, "").replace(/```$/i, "").trim();
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
const start = trimmed.indexOf("{");
|
||||
const end = trimmed.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed.slice(start, end + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item || "").trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value
|
||||
.split(/\n|;/)
|
||||
.map((item) => item.replace(/^[-*]\s+/, "").trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function inferColors(name: string, description: string, sourceFacts: SourceFacts): string[] {
|
||||
const text = `${name} ${description} ${sourceFacts.sourceName || ""}`.toLowerCase();
|
||||
if (text.includes("starbucks")) return ["#006241", "#d4e9e2", "#1e3932", "#ffffff", "#00754a", "#cba258"];
|
||||
if (text.includes("incorta")) return ["#ffffff", "#f05a28", "#1e6bff", "#172033", "#596579"];
|
||||
if (text.includes("qbr") || text.includes("sprint") || text.includes("green")) {
|
||||
return ["#07382f", "#eaf4ef", "#0f4a3c", "#ffffff", "#2d6a4f", "#8fb9a8"];
|
||||
}
|
||||
return ["#ffffff", "#f05a28", "#1e6bff", "#172033", "#596579"];
|
||||
}
|
||||
|
||||
function inferLayoutRules(description: string): string[] {
|
||||
const rules = [
|
||||
"Keep slide chrome, title placement, section spacing, and footer behavior consistent across every slide.",
|
||||
"Use a repeatable grid so content can move between one-column, two-column, and multi-column layouts without redesign."
|
||||
];
|
||||
if (/1,\s*2,\s*3,\s*(or\s*)?4|column/i.test(description)) {
|
||||
rules.push("Support one-, two-, three-, and four-column content modules.");
|
||||
}
|
||||
if (/row/i.test(description)) {
|
||||
rules.push("Allow one or two stacked rows inside each content column while preserving shared gutters.");
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
function applyDesignTemplateToStyle(style: StylePack, designTemplate?: DesignTemplateInput): StylePack {
|
||||
const colors = (designTemplate?.colors || []).map(normalizeHexColor).filter(Boolean);
|
||||
if (!colors.length) return style;
|
||||
|
||||
const lightColors = colors.filter((color) => colorLuminance(color) > 0.78);
|
||||
const darkColors = colors.filter((color) => colorLuminance(color) < 0.42);
|
||||
const saturated = colors.filter((color) => colorLuminance(color) <= 0.78 && colorLuminance(color) >= 0.12);
|
||||
const background = lightColors[0] || style.colors.background;
|
||||
const surface = lightColors[1] || lightColors[0] || style.colors.surface;
|
||||
const text = darkColors[0] || style.colors.text;
|
||||
const primary = saturated[0] || darkColors[0] || style.colors.primary;
|
||||
const secondary = saturated[1] || primary || style.colors.secondary;
|
||||
const accent = saturated[2] || secondary || style.colors.accent;
|
||||
|
||||
return {
|
||||
...style,
|
||||
id: designTemplate?.id || style.id,
|
||||
name: designTemplate?.name ? `${style.name} / ${designTemplate.name}` : style.name,
|
||||
colors: {
|
||||
...style.colors,
|
||||
background: stripHash(background),
|
||||
surface: stripHash(surface),
|
||||
text: stripHash(text),
|
||||
mutedText: stripHash(darkColors[1] || style.colors.mutedText),
|
||||
primary: stripHash(primary),
|
||||
secondary: stripHash(secondary),
|
||||
accent: stripHash(accent),
|
||||
line: stripHash(lightColors[2] || style.colors.line)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHexColor(value: string): string {
|
||||
const trimmed = String(value || "").trim();
|
||||
const hex = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
|
||||
if (/^[0-9a-f]{3}$/i.test(hex)) {
|
||||
return `#${hex
|
||||
.split("")
|
||||
.map((char) => `${char}${char}`)
|
||||
.join("")}`.toLowerCase();
|
||||
}
|
||||
if (/^[0-9a-f]{6}$/i.test(hex)) return `#${hex.toLowerCase()}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function stripHash(value: string): string {
|
||||
const normalized = normalizeHexColor(value);
|
||||
return normalized ? normalized.slice(1).toUpperCase() : value.replace(/^#/, "").toUpperCase();
|
||||
}
|
||||
|
||||
function colorLuminance(value: string): number {
|
||||
const normalized = normalizeHexColor(value);
|
||||
if (!normalized) return 0.5;
|
||||
const channels = [1, 3, 5].map((index) => Number.parseInt(normalized.slice(index, index + 2), 16) / 255);
|
||||
const [r, g, b] = channels.map((channel) =>
|
||||
channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
|
||||
);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
function imageDimensions(buffer: Buffer, mimeType: string): { width: number; height: number } | null {
|
||||
if (mimeType === "image/png" || buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||||
if (buffer.length >= 24) {
|
||||
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
||||
}
|
||||
}
|
||||
|
||||
if (mimeType === "image/jpeg" || (buffer[0] === 0xff && buffer[1] === 0xd8)) {
|
||||
let offset = 2;
|
||||
while (offset + 9 < buffer.length) {
|
||||
if (buffer[offset] !== 0xff) break;
|
||||
const marker = buffer[offset + 1];
|
||||
const size = buffer.readUInt16BE(offset + 2);
|
||||
if (marker >= 0xc0 && marker <= 0xc3) {
|
||||
return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
|
||||
}
|
||||
offset += 2 + size;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function truncateForPrompt(value: string): string | undefined {
|
||||
const trimmed = value.replace(/\s+/g, " ").trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.slice(0, 4000);
|
||||
}
|
||||
|
||||
function sourceFromText(content: string): SourceDocument {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
|
|
@ -368,6 +808,11 @@ function stringField(value: unknown): string | undefined {
|
|||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function combineInstructions(...values: Array<string | undefined>): string | undefined {
|
||||
const parts = values.map((value) => value?.trim()).filter(Boolean);
|
||||
return parts.length ? parts.join("\n\n") : undefined;
|
||||
}
|
||||
|
||||
async function exists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(targetPath);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,25 @@
|
|||
import React, { DragEvent, useMemo, useRef, useState } from "react";
|
||||
import React, { DragEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Download, ExternalLink, FileText, Image as ImageIcon, Loader2, Paperclip, Upload, WandSparkles, X } from "lucide-react";
|
||||
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;
|
||||
|
|
@ -16,6 +33,25 @@ type DeckResult = {
|
|||
};
|
||||
};
|
||||
|
||||
type DesignTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
summary?: string;
|
||||
sourceName?: string;
|
||||
colors: string[];
|
||||
layoutRules?: string[];
|
||||
componentRules?: string[];
|
||||
slideFamilies?: string[];
|
||||
prompt?: string;
|
||||
analysisSource?: "built-in" | "model-service-gateway" | "heuristic";
|
||||
analysisStatus?: "built-in" | "ai-analyzed" | "fallback";
|
||||
analysisNote?: string;
|
||||
gatewayRequestId?: string;
|
||||
updatedAt: string;
|
||||
locked?: boolean;
|
||||
};
|
||||
|
||||
const initialContent = `# Customer Discovery Notes
|
||||
|
||||
Paste notes, transcript excerpts, or brief content here.
|
||||
|
|
@ -24,16 +60,125 @@ Paste notes, transcript excerpts, or brief content here.
|
|||
- 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.",
|
||||
summary: "Built-in starter style pack for concise executive decks.",
|
||||
colors: ["#ffffff", "#f05a28", "#1e6bff", "#172033", "#596579"],
|
||||
layoutRules: ["Use consistent title placement, compact panels, and restrained executive slide density."],
|
||||
componentRules: ["Use simple callouts, two-column evidence panels, roadmap cards, and a small footer."],
|
||||
slideFamilies: ["title", "executive-summary", "two-column", "metric-callout", "roadmap", "recommendation"],
|
||||
prompt:
|
||||
"Use the Incorta Starter system: white executive slides, orange accent, restrained blue secondary accents, Aptos typography, and compact business density.",
|
||||
analysisSource: "built-in",
|
||||
analysisStatus: "built-in",
|
||||
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(),
|
||||
summary: templateDescription.trim(),
|
||||
sourceName: templateFile?.name,
|
||||
colors: starterTemplate.colors,
|
||||
layoutRules: inferDraftLayoutRules(templateDescription),
|
||||
componentRules: ["Use repeated slide sections, restrained accents, and consistent icon treatment."],
|
||||
slideFamilies: ["single-column", "two-column", "three-column", "four-column"],
|
||||
analysisSource: "heuristic",
|
||||
analysisStatus: "fallback",
|
||||
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)}`;
|
||||
|
|
@ -41,11 +186,19 @@ function App() {
|
|||
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 && content.trim() === initialContent.trim()) {
|
||||
setContent("");
|
||||
}
|
||||
if (nextFile?.type.startsWith("image/")) {
|
||||
setPreviewUrl(URL.createObjectURL(nextFile));
|
||||
} else {
|
||||
|
|
@ -53,13 +206,32 @@ function App() {
|
|||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent<HTMLLabelElement>) {
|
||||
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) {
|
||||
|
|
@ -68,9 +240,93 @@ function App() {
|
|||
}
|
||||
}
|
||||
|
||||
async function saveTemplate() {
|
||||
const name = templateName.trim() || "Untitled design standard";
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice("Analyzing template...");
|
||||
try {
|
||||
const response = await analyzeTemplate(name);
|
||||
const json = (await response.json()) as { template?: DesignTemplate; error?: string };
|
||||
if (!response.ok || !json.template) throw new Error(json.error || "Template analysis failed");
|
||||
const template = json.template;
|
||||
setTemplates((current) => [
|
||||
starterTemplate,
|
||||
template,
|
||||
...current.filter((item) => item.id !== starterTemplate.id && item.id !== template.id)
|
||||
]);
|
||||
setSelectedTemplateId(template.id);
|
||||
setEditTemplateId(template.id);
|
||||
setEditTemplateName(template.name);
|
||||
setEditTemplateDescription(template.description);
|
||||
setActiveMode("deck-build");
|
||||
setNotice(
|
||||
template.analysisStatus === "ai-analyzed"
|
||||
? `${template.name} analyzed by AI and selected for deck building`
|
||||
: `${template.name} saved with a local fallback and selected for deck building`
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
setNotice(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
@ -84,12 +340,43 @@ function App() {
|
|||
}
|
||||
}
|
||||
|
||||
function designAwareInstructions() {
|
||||
return [
|
||||
instructions,
|
||||
"",
|
||||
"Use the selected design template as a binding design standard.",
|
||||
`Template: ${selectedTemplate.name}.`,
|
||||
selectedTemplate.description,
|
||||
selectedTemplate.summary,
|
||||
selectedTemplate.layoutRules?.length ? `Layout rules:\n- ${selectedTemplate.layoutRules.join("\n- ")}` : "",
|
||||
selectedTemplate.componentRules?.length ? `Component rules:\n- ${selectedTemplate.componentRules.join("\n- ")}` : "",
|
||||
selectedTemplate.slideFamilies?.length ? `Supported slide families: ${selectedTemplate.slideFamilies.join(", ")}.` : "",
|
||||
selectedTemplate.prompt ? `Reusable design prompt: ${selectedTemplate.prompt}` : "",
|
||||
file && content.trim() ? `Attached slide/deck update instructions:\n${content.trim()}` : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function analyzeTemplate(name: string) {
|
||||
const formData = new FormData();
|
||||
formData.set("name", name);
|
||||
formData.set("description", templateDescription.trim());
|
||||
if (templateFile) formData.set("source", templateFile);
|
||||
return fetch("/api/templates/analyze", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
function createDeckFromFile(sourceFile: File) {
|
||||
const formData = new FormData();
|
||||
formData.set("source", sourceFile);
|
||||
formData.set("style", "incorta");
|
||||
formData.set("audience", "executives");
|
||||
formData.set("instructions", instructions);
|
||||
formData.set("instructions", designAwareInstructions());
|
||||
formData.set("designTemplate", JSON.stringify(templateForRequest(selectedTemplate)));
|
||||
if (content.trim()) formData.set("content", content.trim());
|
||||
return fetch("/api/decks/from-source", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
|
|
@ -102,116 +389,520 @@ function App() {
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
style: "incorta",
|
||||
instructions,
|
||||
instructions: designAwareInstructions(),
|
||||
designTemplate: templateForRequest(selectedTemplate),
|
||||
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>Source to deck</h1>
|
||||
<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} disabled={busy}>
|
||||
{busy ? <Loader2 className="spin" size={18} /> : <WandSparkles size={18} />}
|
||||
{busy ? "Analyzing" : "Analyze 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}
|
||||
busy={busy}
|
||||
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: {
|
||||
busy: boolean;
|
||||
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 ${dragging ? "is-dragging" : ""}`}
|
||||
className={`drop-zone ${props.dragging ? "is-dragging" : ""}`}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
props.onDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
onDragLeave={() => props.onDragging(false)}
|
||||
onDrop={props.onDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
ref={props.inputRef}
|
||||
type="file"
|
||||
accept=".md,.markdown,.txt,.pdf,.pptx,image/*"
|
||||
onChange={(event) => setSourceFile(event.target.files?.[0] || null)}
|
||||
accept=".ppt,.pptx,.pdf,image/*"
|
||||
onChange={(event) => props.onFile(event.target.files?.[0] || null)}
|
||||
/>
|
||||
<Upload size={20} />
|
||||
<span>Drop source</span>
|
||||
<button type="button" className="secondary-action" onClick={() => fileInputRef.current?.click()}>
|
||||
<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>{sourceLabel}</span>
|
||||
{file && (
|
||||
<button type="button" className="icon-action" onClick={() => setSourceFile(null)} aria-label="Clear file">
|
||||
<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>
|
||||
|
||||
{previewUrl && (
|
||||
{props.previewUrl && (
|
||||
<div className="image-preview">
|
||||
<img src={previewUrl} alt="" />
|
||||
<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} disabled={props.busy}>
|
||||
{props.busy ? <Loader2 className="spin" size={16} /> : <WandSparkles size={16} />}
|
||||
{props.busy ? "Analyzing template" : "Analyze and 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-card">
|
||||
<div className="template-card-header">
|
||||
<label className="field">
|
||||
<span>Template to apply</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>
|
||||
<p className="template-summary">{active.summary || active.description}</p>
|
||||
<div className="template-meta-row">
|
||||
<AnalysisBadge template={active} />
|
||||
{active.sourceName && <span className="source-chip">{active.sourceName}</span>}
|
||||
</div>
|
||||
</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={instructions} onChange={(event) => setInstructions(event.target.value)} />
|
||||
<input value={props.instructions} onChange={(event) => props.onInstructions(event.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className="field content">
|
||||
<span>Paste source</span>
|
||||
<span>{props.file ? "Update instructions for attached slide / deck" : props.textLabel}</span>
|
||||
<textarea
|
||||
value={content}
|
||||
onPaste={onPaste}
|
||||
onChange={(event) => {
|
||||
setContent(event.target.value);
|
||||
if (file) setSourceFile(null);
|
||||
}}
|
||||
value={props.content}
|
||||
onPaste={props.onPaste}
|
||||
onChange={(event) => props.onContent(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
{file?.type.startsWith("image/") ? <ImageIcon size={18} /> : <FileText size={18} />}
|
||||
<span>Output</span>
|
||||
{templateMode ? <LayoutTemplate size={18} /> : <FileText size={18} />}
|
||||
<span>{templateMode ? "Template" : "Output"}</span>
|
||||
</div>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{!result && !error && <p className="muted">Ready for a source.</p>}
|
||||
{result && (
|
||||
|
||||
{props.error && <p className="error">{props.error}</p>}
|
||||
{props.notice && <p className="notice">{props.notice}</p>}
|
||||
|
||||
{templateMode ? (
|
||||
<div className="result">
|
||||
<strong>{result.title}</strong>
|
||||
<span>{result.slides} slides</span>
|
||||
{result.source?.sourceName && <span className="source-chip">{result.source.sourceName}</span>}
|
||||
<a href={result.pptxUrl}>
|
||||
<strong>{props.template.name}</strong>
|
||||
<Swatches colors={props.template.colors} />
|
||||
<AnalysisBadge template={props.template} />
|
||||
{props.template.description && <p className="template-summary">{props.template.description}</p>}
|
||||
{props.template.summary && props.template.summary !== props.template.description && (
|
||||
<p className="template-summary">{props.template.summary}</p>
|
||||
)}
|
||||
{props.template.layoutRules?.length ? (
|
||||
<ul className="template-rule-list">
|
||||
{props.template.layoutRules.slice(0, 4).map((rule) => (
|
||||
<li key={rule}>{rule}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<span>{props.templates.length} templates</span>
|
||||
{props.template.sourceName && <span className="source-chip">{props.template.sourceName}</span>}
|
||||
{props.template.gatewayRequestId && <span className="source-chip">Gateway {props.template.gatewayRequestId}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!props.result && !props.error && (
|
||||
<div className="result">
|
||||
<p className="muted">Ready for a source.</p>
|
||||
<strong>Using {props.template.name}</strong>
|
||||
<AnalysisBadge template={props.template} />
|
||||
<p className="template-summary">{props.template.summary || props.template.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{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={result.specUrl}>DeckSpec JSON</a>
|
||||
<a href={props.result.specUrl}>DeckSpec JSON</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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 AnalysisBadge(props: { template: DesignTemplate }) {
|
||||
const status = props.template.analysisStatus || props.template.analysisSource || "saved";
|
||||
const label =
|
||||
status === "ai-analyzed"
|
||||
? "AI analyzed"
|
||||
: status === "fallback"
|
||||
? "Local fallback"
|
||||
: status === "built-in"
|
||||
? "Built in"
|
||||
: "Saved";
|
||||
return <span className={`analysis-badge ${status === "ai-analyzed" ? "is-ai" : ""}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function templateForRequest(template: DesignTemplate) {
|
||||
return {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
summary: template.summary,
|
||||
colors: template.colors,
|
||||
layoutRules: template.layoutRules,
|
||||
componentRules: template.componentRules,
|
||||
slideFamilies: template.slideFamilies,
|
||||
prompt: template.prompt,
|
||||
analysisSource: template.analysisSource,
|
||||
sourceName: template.sourceName
|
||||
};
|
||||
}
|
||||
|
||||
function inferDraftLayoutRules(description: string): string[] {
|
||||
const rules = [
|
||||
"Keep title placement, spacing, and section hierarchy consistent across slides.",
|
||||
"Use a repeatable grid for each slide family."
|
||||
];
|
||||
if (/column/i.test(description)) rules.push("Support one-, two-, three-, and four-column compositions.");
|
||||
if (/row/i.test(description)) rules.push("Support one- and two-row modules inside content columns.");
|
||||
return rules;
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ body {
|
|||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
|
@ -22,12 +23,18 @@ button {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
|
@ -72,6 +79,14 @@ h1 {
|
|||
border: 0;
|
||||
}
|
||||
|
||||
.primary-action,
|
||||
.secondary-action,
|
||||
.mode-tab {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
min-height: 40px;
|
||||
padding: 0 16px;
|
||||
|
|
@ -101,6 +116,33 @@ h1 {
|
|||
border: 1px solid #d8dee9;
|
||||
}
|
||||
|
||||
.mode-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d8dee9;
|
||||
border-radius: 6px;
|
||||
color: #172033;
|
||||
background: #fff;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.mode-tab.is-active {
|
||||
color: #fff;
|
||||
background: #172033;
|
||||
border-color: #172033;
|
||||
}
|
||||
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
|
|
@ -117,6 +159,7 @@ h1 {
|
|||
grid-template-columns: auto auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 68px;
|
||||
padding: 14px;
|
||||
border: 1px dashed #aab4c4;
|
||||
|
|
@ -156,15 +199,19 @@ h1 {
|
|||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
.result-panel,
|
||||
.image-preview {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border: 1px solid #d8dee9;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
input {
|
||||
input,
|
||||
select {
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
|
@ -176,6 +223,64 @@ textarea {
|
|||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.content.compact textarea {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.template-select {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid #d8dee9;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.template-card-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.template-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #d8dee9;
|
||||
}
|
||||
|
||||
.wide-action {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -211,6 +316,10 @@ textarea {
|
|||
color: #b42318;
|
||||
}
|
||||
|
||||
.notice {
|
||||
color: #137333;
|
||||
}
|
||||
|
||||
.result {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
|
@ -228,6 +337,40 @@ textarea {
|
|||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.analysis-badge {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
color: #596579;
|
||||
background: #f4f6fa;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.analysis-badge.is-ai {
|
||||
color: #0b6b38;
|
||||
background: #e9f7ef;
|
||||
}
|
||||
|
||||
.template-summary {
|
||||
margin: 0;
|
||||
color: #596579;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.template-rule-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: #596579;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.result a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -258,13 +401,26 @@ textarea {
|
|||
}
|
||||
|
||||
.topbar-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.mode-tabs {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.main-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.template-select {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.template-card-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
|
@ -274,3 +430,24 @@ textarea {
|
|||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.topbar-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar-actions > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
min-height: 48px;
|
||||
padding: 8px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.mode-tabs {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue