Add Slide Factory mode workbench
This commit is contained in:
parent
84adc53bd0
commit
9855b92605
2 changed files with 763 additions and 80 deletions
|
|
@ -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 { 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";
|
import "./styles.css";
|
||||||
|
|
||||||
|
type Mode = "template-build" | "deck-build" | "template-edit" | "deck-edit";
|
||||||
|
|
||||||
type DeckResult = {
|
type DeckResult = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -16,6 +33,16 @@ type DeckResult = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DesignTemplate = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
sourceName?: string;
|
||||||
|
colors: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
locked?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const initialContent = `# Customer Discovery Notes
|
const initialContent = `# Customer Discovery Notes
|
||||||
|
|
||||||
Paste notes, transcript excerpts, or brief content here.
|
Paste notes, transcript excerpts, or brief content here.
|
||||||
|
|
@ -24,16 +51,111 @@ Paste notes, transcript excerpts, or brief content here.
|
||||||
- Build an executive narrative.
|
- Build an executive narrative.
|
||||||
- Render a PPTX for review.`;
|
- 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() {
|
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 [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 [file, setFile] = useState<File | null>(null);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
const [dragging, setDragging] = useState(false);
|
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 [result, setResult] = useState<DeckResult | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | 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 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(() => {
|
const sourceLabel = useMemo(() => {
|
||||||
if (file) return `${file.name} · ${formatBytes(file.size)}`;
|
if (file) return `${file.name} · ${formatBytes(file.size)}`;
|
||||||
|
|
@ -41,6 +163,11 @@ function App() {
|
||||||
return chars ? `${chars.toLocaleString()} characters pasted` : "No source selected";
|
return chars ? `${chars.toLocaleString()} characters pasted` : "No source selected";
|
||||||
}, [content, file]);
|
}, [content, file]);
|
||||||
|
|
||||||
|
const templateSourceLabel = useMemo(() => {
|
||||||
|
if (templateFile) return `${templateFile.name} · ${formatBytes(templateFile.size)}`;
|
||||||
|
return "No design source selected";
|
||||||
|
}, [templateFile]);
|
||||||
|
|
||||||
function setSourceFile(nextFile: File | null) {
|
function setSourceFile(nextFile: File | null) {
|
||||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
setFile(nextFile);
|
setFile(nextFile);
|
||||||
|
|
@ -53,13 +180,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();
|
event.preventDefault();
|
||||||
setDragging(false);
|
setDragging(false);
|
||||||
const dropped = event.dataTransfer.files?.[0];
|
const dropped = event.dataTransfer.files?.[0];
|
||||||
if (dropped) setSourceFile(dropped);
|
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>) {
|
async function onPaste(event: React.ClipboardEvent<HTMLTextAreaElement>) {
|
||||||
const image = Array.from(event.clipboardData.files).find((item) => item.type.startsWith("image/"));
|
const image = Array.from(event.clipboardData.files).find((item) => item.type.startsWith("image/"));
|
||||||
if (image) {
|
if (image) {
|
||||||
|
|
@ -68,9 +214,78 @@ function App() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function createDeck() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
try {
|
try {
|
||||||
const response = file ? await createDeckFromFile(file) : await createDeckFromText();
|
const response = file ? await createDeckFromFile(file) : await createDeckFromText();
|
||||||
|
|
@ -84,12 +299,23 @@ function App() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function designAwareInstructions() {
|
||||||
|
return [
|
||||||
|
instructions,
|
||||||
|
"",
|
||||||
|
`Selected design template: ${selectedTemplate.name}.`,
|
||||||
|
selectedTemplate.description
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function createDeckFromFile(sourceFile: File) {
|
function createDeckFromFile(sourceFile: File) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.set("source", sourceFile);
|
formData.set("source", sourceFile);
|
||||||
formData.set("style", "incorta");
|
formData.set("style", "incorta");
|
||||||
formData.set("audience", "executives");
|
formData.set("audience", "executives");
|
||||||
formData.set("instructions", instructions);
|
formData.set("instructions", designAwareInstructions());
|
||||||
return fetch("/api/decks/from-source", {
|
return fetch("/api/decks/from-source", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData
|
body: formData
|
||||||
|
|
@ -102,116 +328,455 @@ function App() {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
style: "incorta",
|
style: "incorta",
|
||||||
instructions,
|
instructions: designAwareInstructions(),
|
||||||
input: { type: "markdown", content }
|
input: { type: "markdown", content }
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const title = modeTabs.find((mode) => mode.id === activeMode)?.label || "Slide Factory";
|
||||||
|
|
||||||
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>Source to deck</h1>
|
<h1>{title}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
<a className="secondary-action" href="/google-slides-workflow.html">
|
<a className="secondary-action" href="/google-slides-workflow.html">
|
||||||
<ExternalLink size={16} />
|
<ExternalLink size={16} />
|
||||||
Google Slides workflow
|
Google Slides workflow
|
||||||
</a>
|
</a>
|
||||||
<button className="primary-action" onClick={createDeck} disabled={busy}>
|
{activeMode === "template-build" && (
|
||||||
{busy ? <Loader2 className="spin" size={18} /> : <WandSparkles size={18} />}
|
<button className="primary-action" onClick={saveTemplate}>
|
||||||
{busy ? "Building" : "Build deck"}
|
<Save size={18} />
|
||||||
</button>
|
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>
|
</div>
|
||||||
</header>
|
</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">
|
<div className="main-grid">
|
||||||
<section className="source-pane">
|
<section className="source-pane">
|
||||||
<label
|
{activeMode === "template-build" && (
|
||||||
className={`drop-zone ${dragging ? "is-dragging" : ""}`}
|
<TemplateBuildPane
|
||||||
onDragOver={(event) => {
|
dragging={templateDragging}
|
||||||
event.preventDefault();
|
inputRef={templateInputRef}
|
||||||
setDragging(true);
|
name={templateName}
|
||||||
}}
|
previewUrl={templatePreviewUrl}
|
||||||
onDragLeave={() => setDragging(false)}
|
sourceLabel={templateSourceLabel}
|
||||||
onDrop={onDrop}
|
description={templateDescription}
|
||||||
>
|
onDrop={onTemplateDrop}
|
||||||
<input
|
onDragging={setTemplateDragging}
|
||||||
ref={fileInputRef}
|
onFile={setTemplateSourceFile}
|
||||||
type="file"
|
onName={setTemplateName}
|
||||||
accept=".md,.markdown,.txt,.pdf,.pptx,image/*"
|
onDescription={setTemplateDescription}
|
||||||
onChange={(event) => setSourceFile(event.target.files?.[0] || null)}
|
onSave={saveTemplate}
|
||||||
/>
|
/>
|
||||||
<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">
|
{activeMode === "deck-build" && (
|
||||||
<span>Instructions</span>
|
<DeckBuildPane
|
||||||
<input value={instructions} onChange={(event) => setInstructions(event.target.value)} />
|
content={content}
|
||||||
</label>
|
dragging={dragging}
|
||||||
|
file={file}
|
||||||
<label className="field content">
|
fileInputRef={fileInputRef}
|
||||||
<span>Paste source</span>
|
instructions={instructions}
|
||||||
<textarea
|
previewUrl={previewUrl}
|
||||||
value={content}
|
selectedTemplateId={selectedTemplateId}
|
||||||
|
sourceLabel={sourceLabel}
|
||||||
|
templates={templates}
|
||||||
|
onContent={setContent}
|
||||||
|
onDragging={setDragging}
|
||||||
|
onDrop={onSourceDrop}
|
||||||
|
onFile={setSourceFile}
|
||||||
|
onInstructions={setInstructions}
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
onChange={(event) => {
|
onTemplate={setSelectedTemplateId}
|
||||||
setContent(event.target.value);
|
|
||||||
if (file) setSourceFile(null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
)}
|
||||||
|
|
||||||
|
{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>
|
</section>
|
||||||
|
|
||||||
<aside className="result-panel">
|
<OutputPanel
|
||||||
<div className="result-title">
|
activeMode={activeMode}
|
||||||
{file?.type.startsWith("image/") ? <ImageIcon size={18} /> : <FileText size={18} />}
|
error={error}
|
||||||
<span>Output</span>
|
notice={notice}
|
||||||
</div>
|
result={result}
|
||||||
{error && <p className="error">{error}</p>}
|
template={activeTemplatePreview}
|
||||||
{!result && !error && <p className="muted">Ready for a source.</p>}
|
templates={templates}
|
||||||
{result && (
|
/>
|
||||||
<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}>
|
|
||||||
<Download size={16} />
|
|
||||||
PPTX
|
|
||||||
</a>
|
|
||||||
<a href={result.specUrl}>DeckSpec JSON</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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) {
|
function formatBytes(value: number) {
|
||||||
if (value < 1024) return `${value} B`;
|
if (value < 1024) return `${value} B`;
|
||||||
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
|
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ body {
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input,
|
input,
|
||||||
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
@ -28,6 +29,7 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
|
width: 100%;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
@ -72,6 +74,14 @@ h1 {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.primary-action,
|
||||||
|
.secondary-action,
|
||||||
|
.mode-tab {
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.primary-action {
|
.primary-action {
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
|
|
@ -101,6 +111,33 @@ h1 {
|
||||||
border: 1px solid #d8dee9;
|
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 {
|
.main-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 320px;
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
|
|
@ -117,6 +154,7 @@ h1 {
|
||||||
grid-template-columns: auto auto 1fr auto;
|
grid-template-columns: auto auto 1fr auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
min-height: 68px;
|
min-height: 68px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border: 1px dashed #aab4c4;
|
border: 1px dashed #aab4c4;
|
||||||
|
|
@ -156,15 +194,19 @@ h1 {
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
|
select,
|
||||||
textarea,
|
textarea,
|
||||||
.result-panel,
|
.result-panel,
|
||||||
.image-preview {
|
.image-preview {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
border: 1px solid #d8dee9;
|
border: 1px solid #d8dee9;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input,
|
||||||
|
select {
|
||||||
height: 42px;
|
height: 42px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
|
|
@ -176,6 +218,41 @@ textarea {
|
||||||
line-height: 1.45;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.image-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -211,6 +288,10 @@ textarea {
|
||||||
color: #b42318;
|
color: #b42318;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
color: #137333;
|
||||||
|
}
|
||||||
|
|
||||||
.result {
|
.result {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
@ -228,6 +309,13 @@ textarea {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-summary {
|
||||||
|
margin: 0;
|
||||||
|
color: #596579;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.result a {
|
.result a {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -258,13 +346,22 @@ textarea {
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-actions {
|
.topbar-actions {
|
||||||
|
width: 100%;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mode-tabs {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.main-grid {
|
.main-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-select {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.drop-zone {
|
.drop-zone {
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
}
|
}
|
||||||
|
|
@ -274,3 +371,24 @@ textarea {
|
||||||
justify-self: start;
|
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