Files
m3e-canvas/web/vendor/components/PromptPanel.tsx
T
pastilhasandClaude Fable 5 e99b4afbf9 the vendoring machinery: one command reproduces the whole state
scripts/vendor.sh clones upstream, rewrites @/ imports to relative
paths, and applies scripts/apply-host.py. web/host.ts is ours and gets
placed into the vendored tree, because upstream declined it.

a script rather than a .patch, and that is not a preference. between
cfde698 and 5c83ac6 — one day — upstream changed an import line,
re-indented the component from 6 spaces to 8, and added an editAccess
guard. a context diff fails on all three, none of which concern us. so
every anchor matches structure with flexible whitespace and re-indents
to whatever it finds, and a missing anchor stops the run loudly.

all three prompt-export paths are wired, which is what upstream said a
complete version needs. the other two clipboard writes are ShareMenu's
share-link and ask-an-AI blurb, and the reason they are excluded is
written down so the count does not read as the same oversight twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 12:04:34 +01:00

153 lines
4.4 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { buildPrompt } from "../lib/prompt";
import { Doc, Palette, Platform, defaultPlatformOf } from "../lib/tokens";
import { Icon } from "./M3Node";
import { Field, IconBtn, Segmented } from "./ui";
import { t, useLang } from "../lib/i18n";
import { useHost } from "../lib/host";
export function PromptPanel({
doc,
widths,
palette: p,
onDoc,
}: {
doc: Doc;
widths: Record<string, number>;
palette: Palette;
onDoc: (patch: Partial<Doc>) => void;
}) {
const lang = useLang();
const generated = useMemo(() => buildPrompt(doc, widths, undefined, lang), [doc, widths, lang]);
const edited = doc.promptEdit !== undefined;
const text = edited ? doc.promptEdit! : generated;
const host = useHost();
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const t = setTimeout(() => setCopied(false), 1400);
return () => clearTimeout(t);
}, [copied]);
const copy = async () => {
try {
let handled = false;
try { handled = host.onExport?.(text, null) === true; } catch {}
if (handled) { setCopied(true); return; }
await navigator.clipboard.writeText(text);
setCopied(true);
} catch {}
};
const projectButton = (icon: string, label: string, onClick: () => void) => (
<button
onClick={onClick}
className="m3-press"
style={{
flex: 1,
height: 42,
borderRadius: 21,
border: "none",
background: p.secondaryContainer,
color: p.onSecondaryContainer,
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 7,
}}
>
<Icon name={icon} size={19} />
{label}
</button>
);
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", padding: 12, gap: 10 }}>
<Field
value={doc.title}
onChange={(title) => onDoc({ title })}
placeholder={t("appName", lang)}
p={p}
icon="smartphone"
/>
<Field
value={doc.brief}
onChange={(brief) => onDoc({ brief })}
placeholder={t("brief", lang)}
p={p}
icon="lightbulb"
multiline
rows={3}
/>
<Segmented<Platform>
options={[
{ key: "android", icon: "android", label: "Android", title: t("targetAndroid", lang) },
{ key: "web", icon: "language", label: "Web", title: t("targetWeb", lang) },
]}
value={doc.platform ?? defaultPlatformOf(doc.frames, doc.frame)}
onChange={(platform) => onDoc({ platform })}
p={p}
height={40}
/>
<div style={{ position: "relative", flex: 1, minHeight: 0, display: "flex" }}>
<textarea
className="no-scrollbar"
value={text}
onChange={(e) => onDoc({ promptEdit: e.target.value })}
spellCheck={false}
aria-label={t("prompt", lang)}
style={{
flex: 1,
minHeight: 0,
width: "100%",
borderRadius: 18,
border: "none",
background: p.surfaceContainerLow,
padding: edited ? "14px 14px 48px" : 14,
fontSize: 13,
lineHeight: 1.75,
color: p.onSurface,
fontFamily: "inherit",
resize: "none",
outline: "none",
boxSizing: "border-box",
}}
/>
{edited && (
<div style={{ position: "absolute", right: 8, bottom: 8 }}>
<IconBtn icon="undo" p={p} size={32} onClick={() => onDoc({ promptEdit: undefined })} title={t("promptReset", lang)} />
</div>
)}
</div>
<button
onClick={copy}
className="m3-press"
style={{
height: 48,
borderRadius: 24,
border: "none",
background: copied ? p.tertiaryContainer : p.primary,
color: copied ? p.onTertiaryContainer : p.onPrimary,
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
transition: "background 160ms, color 160ms",
}}
>
<Icon name={copied ? "check" : "content_copy"} size={20} />
{copied ? t("copied", lang) : t("copyPrompt", lang)}
</button>
</div>
);
}