Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,301 @@
---
/**
* Form embed component for Portable Text blocks.
*
* Server-renders the full form with all pages as <fieldset> elements.
* Without JavaScript, all pages are visible as one long form.
* The client-side script enhances with multi-page navigation, AJAX, etc.
*/
interface Props {
node: { formId: string };
}
interface FormField {
id: string;
type: string;
label: string;
name: string;
placeholder?: string;
helpText?: string;
required: boolean;
validation?: {
minLength?: number;
maxLength?: number;
min?: number;
max?: number;
pattern?: string;
patternMessage?: string;
accept?: string;
maxFileSize?: number;
};
options?: Array<{ label: string; value: string }>;
defaultValue?: string;
width: "full" | "half";
condition?: { field: string; op: string; value?: string };
}
interface FormPage {
title?: string;
fields: FormField[];
}
interface FormDefinition {
name: string;
slug: string;
pages: FormPage[];
settings: {
spamProtection: string;
submitLabel: string;
nextLabel?: string;
prevLabel?: string;
};
status: string;
_turnstileSiteKey?: string | null;
}
const { node } = Astro.props;
const formId = node.formId;
// Fetch form definition server-side
const response = await fetch(
new URL("/_emdash/api/plugins/emdash-forms/definition", Astro.url),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: formId }),
}
);
if (!response.ok) return;
const form = (await response.json()) as FormDefinition;
if (!form || form.status !== "active") return;
const submitUrl = `/_emdash/api/plugins/emdash-forms/submit`;
const isMultiPage = form.pages.length > 1;
const turnstileSiteKey = form._turnstileSiteKey;
const hasFiles = form.pages.some((p: FormPage) =>
p.fields.some((f: FormField) => f.type === "file")
);
/** Generate an element ID for a field */
function fieldId(name: string): string {
return `${formId}-${name}`;
}
---
<form
class="ec-form"
method="POST"
action={submitUrl}
enctype={hasFiles ? "multipart/form-data" : undefined}
data-form-id={formId}
data-ec-form
data-pages={isMultiPage ? form.pages.length : undefined}
>
{
form.pages.map((page: FormPage, pageIndex: number) => (
<fieldset
class="ec-form-page"
data-page={pageIndex}
aria-label={page.title || `Page ${pageIndex + 1}`}
>
{isMultiPage && page.title && (
<legend class="ec-form-page-title">{page.title}</legend>
)}
{page.fields.map((field: FormField) => (
<div
class:list={[
"ec-form-field",
`ec-form-field--${field.type}`,
field.width === "half" && "ec-form-field--half",
]}
data-condition={
field.condition ? JSON.stringify(field.condition) : undefined
}
>
{field.type !== "hidden" && field.type !== "checkbox" && (
<label class="ec-form-label" for={fieldId(field.name)}>
{field.label}
{field.required && (
<span class="ec-form-required" aria-label="required">
*
</span>
)}
</label>
)}
{[
"text",
"email",
"tel",
"url",
"number",
"date",
"hidden",
].includes(field.type) && (
<input
type={field.type as astroHTML.JSX.HTMLInputTypeAttribute}
class={field.type !== "hidden" ? "ec-form-input" : undefined}
id={fieldId(field.name)}
name={field.name}
placeholder={field.placeholder}
required={field.required}
minlength={field.validation?.minLength}
maxlength={field.validation?.maxLength}
min={field.validation?.min}
max={field.validation?.max}
pattern={field.validation?.pattern}
value={field.defaultValue}
/>
)}
{field.type === "file" && (
<input
type="file"
class="ec-form-input"
id={fieldId(field.name)}
name={field.name}
required={field.required}
accept={field.validation?.accept}
/>
)}
{field.type === "textarea" && (
<textarea
class="ec-form-input"
id={fieldId(field.name)}
name={field.name}
placeholder={field.placeholder}
required={field.required}
minlength={field.validation?.minLength}
maxlength={field.validation?.maxLength}
>
{field.defaultValue || ""}
</textarea>
)}
{field.type === "select" && (
<select
class="ec-form-input"
id={fieldId(field.name)}
name={field.name}
required={field.required}
>
{(field.options || []).map((o) => (
<option
value={o.value}
selected={o.value === field.defaultValue}
>
{o.label}
</option>
))}
</select>
)}
{field.type === "radio" && (
<fieldset class="ec-form-radio-group" role="radiogroup">
{(field.options || []).map((o) => (
<label class="ec-form-radio-label">
<input
type="radio"
name={field.name}
value={o.value}
checked={o.value === field.defaultValue}
required={field.required}
/>{" "}
{o.label}
</label>
))}
</fieldset>
)}
{field.type === "checkbox" && (
<label class="ec-form-checkbox-label">
<input
type="checkbox"
class="ec-form-input"
id={fieldId(field.name)}
name={field.name}
value={field.defaultValue || "1"}
required={field.required}
/>{" "}
{field.label}
</label>
)}
{field.type === "checkbox-group" && (
<fieldset class="ec-form-checkbox-group">
{(field.options || []).map((o) => (
<label class="ec-form-checkbox-label">
<input type="checkbox" name={field.name} value={o.value} />{" "}
{o.label}
</label>
))}
</fieldset>
)}
{field.helpText && (
<span class="ec-form-help">{field.helpText}</span>
)}
<span
class="ec-form-error"
data-error-for={field.name}
aria-live="polite"
/>
</div>
))}
</fieldset>
))
}
{
form.settings.spamProtection === "honeypot" && (
<div
class="ec-form-field"
style="position:absolute;left:-9999px;"
aria-hidden="true"
>
<label for={`${formId}-_hp`}>Leave blank</label>
<input
type="text"
id={`${formId}-_hp`}
name="_hp"
tabindex="-1"
autocomplete="off"
/>
</div>
)
}
{
form.settings.spamProtection === "turnstile" && turnstileSiteKey && (
<div
class="ec-form-turnstile"
data-ec-turnstile
data-sitekey={turnstileSiteKey}
/>
)
}
<input type="hidden" name="formId" value={formId} />
<div class="ec-form-nav">
<button type="button" class="ec-form-prev" data-ec-prev hidden>
{form.settings.prevLabel || "Previous"}
</button>
<button type="button" class="ec-form-next" data-ec-next hidden>
{form.settings.nextLabel || "Next"}
</button>
<button type="submit" class="ec-form-submit">
{form.settings.submitLabel || "Submit"}
</button>
</div>
{
isMultiPage && (
<div class="ec-form-progress" data-ec-progress aria-live="polite" />
)
}
<div class="ec-form-status" data-form-status aria-live="polite"></div>
</form>
<script>
import { initForms } from "@emdash-cms/plugin-forms/client";
initForms();
</script>