feat(demo): add story 1 — Sorano: Rock and Time

This commit is contained in:
2026-06-20 21:19:57 +02:00
parent 42ed59a6b3
commit 8f87155c1d
5508 changed files with 1595740 additions and 124 deletions
@@ -0,0 +1,99 @@
{% extends "forms/field.html.twig" %}
{% macro renderer(key, text, field, scope) %}
{% if text is not iterable %}
<div class="form-row{% if field.value_only %} array-field-value_only{% endif %}"
data-grav-array-type="row">
<span data-grav-array-action="sort" class="fa fa-bars"></span>
{% if field.value_only != true %}
{% if key == '0' and text == '' %}
{% set key = '' %}
{% endif %}
<input
data-grav-array-type="key"
type="text" value="{{ key }}"
{% if field.disabled or isDisabledToggleable %}disabled="disabled"{% endif %}
placeholder="{{ field.placeholder_key|e|t }}" />
{% endif %}
{% if field.value_type == 'textarea' %}
<textarea
data-grav-array-type="value"
name="{{ ((scope ~ field.name)|fieldName) ~ '[' ~ key ~ ']' }}"
placeholder="{{ field.placeholder_value|e|t }}"
{% if field.disabled or isDisabledToggleable %}disabled="disabled"{% endif %}>{{ text }}</textarea>
{% else %}
<input
data-grav-array-type="value"
type="text"
name="{{ ((scope ~ field.name)|fieldName) ~ '[' ~ key ~ ']' }}"
placeholder="{{ field.placeholder_value|e|t }}"
{% if field.disabled or isDisabledToggleable %}disabled="disabled"{% endif %}
value={% if text == 'true' %}true{% elseif text == 'false' %}false{% else %}"{{ text|join(', ')|e }}"{% endif %} />
{% endif %}
<span data-grav-array-action="rem" class="fa fa-minus"></span>
<span data-grav-array-action="add" class="fa fa-plus"></span>
</div>
{% endif %}
{% endmacro %}
{% import _self as array_field %}
{% do assets.addJs('plugin://form/assets/form.vendor.js', { 'group': 'bottom', 'loading': 'defer' }) %}
{% do assets.addJs('plugin://form/assets/form.min.js', { 'group': 'bottom', 'loading': 'defer' }) %}
{% block global_attributes %}
data-grav-array-name="{{ (scope ~ field.name)|fieldName }}"
data-grav-array-keyname="{{ field.placeholder_key|e|t }}"
data-grav-array-valuename="{{ field.placeholder_value|e|t }}"
data-grav-array-textarea="{{ field.value_type == 'textarea' }}"
{{ parent() }}
{% endblock %}
{% block input %}
<div class="{{ field.size }} {{ field.classes }}" data-grav-array-type="container"{% if field.value_only %} data-grav-array-mode="value_only"{% endif %}{{ value|length <= 1 ? ' class="one-child"' : '' }}>
{% if value|length %}
{% for key, text in value -%}
{% if text is not iterable %}
{{ array_field.renderer(key, text, field, scope) }}
{% else %}
{# Backward compatibility for nested arrays (metas) which are not supported anymore #}
{% for subkey, subtext in text -%}
{{ array_field.renderer(key ~ '[' ~ subkey ~ ']', subtext, field, scope) }}
{% endfor %}
{% endif %}
{% endfor %}
{%- else -%}
{# Empty value, mock the entry field#}
<div class="form-row" data-grav-array-type="row">
<span data-grav-array-action="sort" class="fa fa-bars"></span>
{% if field.value_only != true %}
<input
data-grav-array-type="key"
type="text"
{% if field.disabled or isDisabledToggleable %}disabled="disabled"{% endif %}
placeholder="{{ field.placeholder_key|e|t }}" />
{% endif %}
{% if field.value_type == 'textarea' %}
<textarea
data-grav-array-type="value"
name="{{ (scope ~ field.name)|fieldName }}"
{% if field.disabled or isDisabledToggleable %}disabled="disabled"{% endif %}
placeholder="{{ field.placeholder_value|e|t }}"></textarea>
{% else %}
<input
data-grav-array-type="value"
type="text"
name="{{ (scope ~ field.name)|fieldName }}"
{% if field.disabled or isDisabledToggleable %}disabled="disabled"{% endif %}
placeholder="{{ field.placeholder_value|e|t }}" />
{% endif %}
<span data-grav-array-action="rem" class="fa fa-minus"></span>
<span data-grav-array-action="add" class="fa fa-plus"></span>
</div>
{%- endif %}
</div>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "forms/field.html.twig" %}
{% block field %}
{% set avatar = form.value('avatar') %}
{% if avatar %}
<label class="{{ field.classes }}"><img class="{{ field.img_classes }}" style="max-width:200px;" src="{{ base_url_simple ~ '/' ~ (avatar|first).path }}" /></label>
{% else %}
<label class="{{ field.classes }}"><img class="{{ field.img_classes }}" src="https://www.gravatar.com/avatar/{{ form.value('email')|md5 }}?s=200" /></label>
{% endif %}
{% endblock %}
@@ -0,0 +1,35 @@
{% set form_field_outer_data_classes = 'form-data basic-captcha' %}
{% extends "forms/field.html.twig" %}
{% block prepend %}
{% set field_id = field.name|default('default') %}
{% set config_hash = (form.id ~ '_basic_captcha_' ~ field_id)|md5 %}
{% set image_url = url('/forms-basic-captcha-image.jpg') ~ '?field=' ~ config_hash %}
{# Store field configuration in session for image generation #}
{% set global_config = grav.config.get('plugins.form.basic_captcha', {}) %}
{% set merged_config = global_config|merge(field) %}
{% do store_basic_captcha_config(config_hash, merged_config) %}
<div class="form-input-addon form-input-prepend"
data-captcha-provider="basic-captcha"
data-field-id="{{ config_hash }}">
<img id="basic-captcha-reload-{{ form.id }}"
src="{{ image_url }}"
alt="human test"
data-base-url="{{ url('/forms-basic-captcha-image.jpg') }}"
data-field-id="{{ config_hash }}" />
<button type="button" id="reload-captcha-{{ form.id }}" class="reload-captcha-button"><svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g fill="currentColor"><path d="M14.74 22.39c4.68-1.24 8-5.49 8-10.4 0-5.95-4.79-10.75-10.75-10.75 -3.11 0-5.78 1.11-7.99 2.95 -.77.64-1.43 1.32-1.98 2.01 -.34.41-.57.75-.69.95 -.22.35-.1.81.25 1.02 .35.21.81.09 1.02-.26 .08-.15.27-.43.56-.79 .49-.62 1.08-1.23 1.76-1.81C6.87 3.67 9.21 2.7 11.94 2.7c5.13 0 9.25 4.12 9.25 9.25 0 4.22-2.86 7.88-6.9 8.94 -.41.1-.64.51-.54.91 .1.4.51.63.91.53Zm-12-14.84V2.99c-.001-.42-.34-.75-.75-.75 -.42 0-.75.33-.75.75v4.56c0 .41.33.75.75.75 .41 0 .75-.34.75-.75Zm-.75.75H4h2.43c.41 0 .75-.34.75-.75 0-.42-.34-.75-.75-.75H4 1.99c-.42 0-.75.33-.75.75 0 .41.33.75.75.75Z"/><path d="M1.25 12c0 1.09.16 2.16.48 3.18 .12.39.54.61.93.49 .39-.13.61-.55.49-.94 -.28-.89-.42-1.81-.42-2.75 0-.42-.34-.75-.75-.75 -.42 0-.75.33-.75.75Zm1.93 6.15c.61.88 1.36 1.67 2.22 2.33 .32.25.79.19 1.05-.14 .25-.33.19-.8-.14-1.06 -.74-.58-1.38-1.25-1.92-2.02 -.24-.34-.71-.43-1.05-.19 -.34.23-.43.7-.19 1.04Zm5.02 3.91c1 .37 2.06.6 3.15.66 .41.02.76-.3.79-.71 .02-.42-.30-.77-.71-.80 -.94-.06-1.85-.25-2.72-.58 -.39-.15-.83.04-.97.43 -.15.38.04.82.43.96Z"/></g></svg></button>
</div>
{% do assets.addJs('plugin://form/assets/captcha/basic-captcha-refresh.js') %}
{% endblock %}
{% block input_attributes %}
type="text"
{% if field.size %}size="{{ field.size }}"{% endif %}
{% if field.minlength is defined or field.validate.min is defined %}minlength="{{ field.minlength | default(field.validate.min) }}"{% endif %}
{% if field.maxlength is defined or field.validate.max is defined %}maxlength="{{ field.maxlength | default(field.validate.max) }}"{% endif %}
{{ parent() }}
{% endblock %}
@@ -0,0 +1,45 @@
{% extends "forms/field.html.twig" %}
{% block label %}{% endblock %}
{% block input %}
{% set formId = form.id ?: form.name %}
{% set endpoint = base_url_relative ~ '/forms-cap/' %}
{% set wasm_url = url('plugin://form/assets/captcha/cap/cap_wasm_bg.wasm') %}
{% set container_id = 'cap-' ~ formId %}
{% set mode = field.mode ?? grav.config.plugins.form.cap.mode ?? 'invisible' %}
{% if mode not in ['invisible', 'checkbox'] %}{% set mode = 'invisible' %}{% endif %}
{% if mode == 'checkbox' %}
{# The <cap-widget> renders its own <input type="hidden" name="cap-token"> internally. #}
<div class="cap-container"
data-form-id="{{ formId }}"
data-captcha-provider="cap"
data-cap-mode="checkbox"
data-cap-wasm-url="{{ wasm_url }}">
<cap-widget
id="{{ container_id }}"
data-cap-api-endpoint="{{ endpoint }}"
data-cap-hidden-field-name="cap-token"></cap-widget>
</div>
{% else %}
{# Invisible mode: no visible UI. cap-handler.js wires up the solve + submit-intercept. #}
<div class="cap-container cap-invisible"
data-form-id="{{ formId }}"
data-captcha-provider="cap"
data-cap-mode="invisible"
data-cap-api-endpoint="{{ endpoint }}"
data-cap-wasm-url="{{ wasm_url }}"
aria-hidden="true"
style="display:none;">
<input type="hidden" name="cap-token" value="" />
</div>
{% endif %}
{% do assets.addJs('plugin://form/assets/captcha/cap-handler.js', { group: 'bottom', priority: 110 }) %}
{% do assets.addJs('plugin://form/assets/captcha/cap/cap.min.js', { group: 'bottom', loading: 'defer', priority: 100 }) %}
{% endblock %}
@@ -0,0 +1,18 @@
{% extends "forms/field.html.twig" %}
{% block field %}
{# This main captcha field serves as a router to the appropriate provider template #}
{% set provider = field.provider %}
{% if provider is not defined or provider == null %}
{% set provider = 'recaptcha' %}
{% endif %}
{% set template = 'forms/fields/' ~ provider ~ '/' ~ provider ~ '.html.twig' %}
{% if captcha_template_exists(template) %}
{% include template with {'field': field} %}
{% else %}
<div class="form-error" style="color:#c00000;">ERROR - unknown captcha provider: <strong>{{ provider }}</strong></div>
{% endif %}
{% endblock %}
@@ -0,0 +1,46 @@
{% extends "forms/field.html.twig" %}
{% block label %}
{% endblock %}
{% block input %}
{% set id = field.id|default(field.name)|hyphenize %}
<div class="{{ form_field_wrapper_classes ?: 'form-input-wrapper' }} {{ field.size }} {{ field.wrapper_classes }}">
<input
{# required attribute structures #}
name="{{ (scope ~ field.name)|fieldName }}"
value="{{ field.value ?? '1' }}"
type="checkbox"
{% if value == (field.value ?? '1') %} checked="checked" {% endif %}
{# input attribute structures #}
{% block input_attributes %}
id="{{ id|e }}"
class="{{ form_field_checkbox_classes }} {{ field.classes }}"
{% if field.style is defined %}style="{{ field.style|e }}" {% endif %}
{% if field.disabled or isDisabledToggleable %}disabled="disabled"{% endif %}
{% if field.autofocus in ['on', 'true', 1] %}autofocus="autofocus"{% endif %}
{% if field.novalidate in ['on', 'true', 1] %}novalidate="novalidate"{% endif %}
{% if required %}required="required"{% endif %}
{% if field.tabindex %}tabindex="{{ field.tabindex }}"{% endif %}
{% if field.attributes is defined %}
{% for key,attribute in field.attributes %}
{% if attribute|of_type('array') %}
{{ attribute.name }}="{{ attribute.value|e('html_attr') }}"
{% else %}
{{ key }}="{{ attribute|e('html_attr') }}"
{% endif %}
{% endfor %}
{% endif %}
{% endblock %}
/>
<label style="display:inline;" class="inline" for="{{ id|e }}">
{% if field.markdown %}
{{ field.label|t|markdown(false) }}
{% else %}
{{ field.label|t|e }}
{% endif %}
{{ field.validate.required in ['on', 'true', 1] ? '<span class="required">*</span>' }}
</label>
</div>
{% endblock %}
@@ -0,0 +1,41 @@
{% extends "forms/field.html.twig" %}
{% block global_attributes %}
{{ parent() }}
data-grav-keys="{{ field.use == 'keys' ? 'true' : 'false' }}"
data-grav-field-name="{{ (scope ~ field.name)|fieldName }}"
{% endblock %}
{% block input %}
{% set value = (value is null ? field.default : value) %}
{% if field.use == 'keys' and field.default %}
{% set value = field.default|merge(value) %}
{% endif %}
<div class="checkboxes {{ form_field_wrapper_classes }} {{ field.wrapper_classes }}">
{% for key, text in field.options %}
{% set id = field.id|default(field.name)|hyphenize ~ '-' ~ key %}
{% set name = field.use == 'keys' ? key : id %}
{% set val = field.use == 'keys' ? '1' : key %}
{% set checked = (field.use == 'keys' ? value[key] : key in value) %}
{% set help = (key in field.help_options|keys ? field.help_options[key] : false) %}
{% set disabled = key in field.disabled_options %}
<input type="checkbox"
id="{{ id|e }}"
value="{{ val|e }}"
name="{{ (scope ~ field.name)|fieldName ~ '[' ~ name ~ ']' }}"
class="{{ form_field_checkbox_classes }} {{ field.classes }}"
{% if checked %}checked="checked"{% endif %}
{% if disabled %}disabled="disabled"{% endif %}
>
<label style="display: inline; {% if disabled %}opacity: 0.6; cursor: no-drop;{% endif %}" for="{{ id|e }}">
{% if help %}
<span class="hint--bottom" data-hint="{{ help|t|e('html_attr') }}">{{ text|t|e }}</span>
{% else %}
{{ text|t|e }}
{% endif %}
</label>
{% endfor %}
</div>
{% endblock %}
@@ -0,0 +1,6 @@
{% extends "forms/field.html.twig" %}
{% block input_attributes %}
type="color"
{{ parent() }}
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "forms/field.html.twig" %}
{% block field %}
{% embed 'forms/default/fields.html.twig' with {name: name, fields: field.fields} %}
{% block outer_markup_field_open %}<div class="form-column {{ field.classes }}">{% endblock %}
{% block outer_markup_field_close %}</div>{% endblock %}
{% endembed %}
{% endblock %}
@@ -0,0 +1,7 @@
{% extends "forms/field.html.twig" %}
{% block field %}
<div class="form-columns {{ field.classes }}">
{% include 'forms/default/fields.html.twig' with {name: field.name|parent_field, fields: field.fields} %}
</div>
{% endblock %}
@@ -0,0 +1,19 @@
{% extends "forms/field.html.twig" %}
{% block field %}
{% set value = evaluate(field.condition) %}
{% set value = value == 'true' ? 1 : value %}
{% set value = value == 'false' ? 0 : value %}
{% if value %}
{% if field.classes %}
<div class="{{ field.classes }}">
{% endif %}
{% include 'forms/default/fields.html.twig' with {name: field.name, fields: field.fields} %}
{% if field.classes %}
</div>
{% endif %}
{% endif %}
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "forms/field.html.twig" %}
{% block input_attributes %}
type="date"
{% if field.validate.min %}min="{{ field.validate.min }}"{% endif %}
{% if field.validate.max %}max="{{ field.validate.max }}"{% endif %}
{{ parent() }}
{% endblock %}
@@ -0,0 +1 @@
{{ value ? value|date('m/d/Y')|e }}
@@ -0,0 +1,2 @@
{# DEPRECATED. Switched to Text field until implemented properly #}
{% extends "forms/fields/text/text.html.twig" %}
@@ -0,0 +1 @@
{{ value ? value|date('m/d/Y \\a\\t g:i A')|e }}
@@ -0,0 +1,21 @@
{% extends "forms/field.html.twig" %}
{% if field.file %}
{% set content = read_file(field.file) %}
{% else %}
{% set content = field.content %}
{% endif %}
{% block input %}
<div class="form-display-wrapper {{ field.size }} {{ field.classes }}" {% if field.id is defined %}id="{{ field.id|e }}" {% endif %}>
{% if field.markdown %}
{{ content|t|markdown|raw }}
{% else %}
{% if field.evaluate %}
{{ evaluate_twig(content)|raw }}
{% else %}
{{ content|t|raw }}
{% endif %}
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "forms/field.html.twig" %}
{% block input_attributes %}
type="email"
{% if field.multiple in ['on', 'true', 1] %}multiple="multiple"{% endif %}
{% if field.size %}size="{{ field.size }}"{% endif %}
{% if field.minlength is defined or field.validate.min is defined %}minlength="{{ field.minlength | default(field.validate.min) }}"{% endif %}
{% if field.maxlength is defined or field.validate.max is defined %}maxlength="{{ field.maxlength | default(field.validate.max) }}"{% endif %}
{{ parent() }}
{% endblock %}
@@ -0,0 +1,12 @@
{% extends "forms/field.html.twig" %}
{% set scope = field.nest_id ? scope ~ field.name ~ '.' : scope %}
{% block field %}
<fieldset {% if field.id is defined %}id="{{ field.id }}"{% endif %} {% if field.classes is defined %}class="{{ field.classes }}" {% endif %}>
{% if field.legend %}
<legend>{{ field.legend|t }}</legend>
{% endif %}
{% include 'forms/default/fields.html.twig' with {name: field.name, fields: field.fields} %}
</fieldset>
{% endblock %}
@@ -0,0 +1,136 @@
{% extends "forms/field.html.twig" %}
{% macro bytesToSize(bytes) -%}
{% apply spaceless %}
{% set kilobyte = 1024 %}
{% set megabyte = kilobyte * 1024 %}
{% set gigabyte = megabyte * 1024 %}
{% set terabyte = gigabyte * 1024 %}
{% if bytes < kilobyte %}
{{ bytes ~ ' B' }}
{% elseif bytes < megabyte %}
{{ (bytes / kilobyte)|number_format(2, '.') ~ ' KB' }}
{% elseif bytes < gigabyte %}
{{ (bytes / megabyte)|number_format(2, '.') ~ ' MB' }}
{% elseif bytes < terabyte %}
{{ (bytes / gigabyte)|number_format(2, '.') ~ ' GB' }}
{% else %}
{{ (bytes / terabyte)|number_format(2, '.') ~ ' TB' }}
{% endif %}
{% endapply %}
{%- endmacro %}
{% macro preview(path, value, global) %}
{% if value %}
{% set uri = global.grav.uri %}
{% set files = global.files %}
{% set config = global.grav.config %}
{% set route = global.context.route().toString(true) %}
{% set type = global.context.content() is not null ? 'pages' : global.plugin ? 'plugins' : global.theme ? 'themes' : 'config' %}
{% set blueprint_name = global.blueprints.getFilename %}
{% if type == 'pages' %}
{% set blueprint_name = type ~ '/' ~ blueprint_name %}
{% endif %}
{% set blueprint = blueprint_name|base64_encode %}
{% set real_path = value.thumb ?? global.context.media[path].relativePath ?? global.form.getPagePathFromToken(path) %}
{% set remove = global.form.getFileDeleteAjaxRoute(files.name, path).toString(true) ?: uri.addNonce(
global.base_url_relative ~
'/media.json' ~
'/task' ~ config.system.param_sep ~ 'removeFileFromBlueprint' ~
'/proute' ~ config.system.param_sep ~ route|base64_encode ~
'/blueprint' ~ config.system.param_sep ~ blueprint ~
'/type' ~ config.system.param_sep ~ type ~
'/field' ~ config.system.param_sep ~ files.name ~
'/path' ~ config.system.param_sep ~ value.path|base64_encode, 'admin-form', 'admin-nonce') %}
{% set file = value|merge({remove: remove, path: value.thumb_url ?? (uri.rootUrl == '/' ? '/' : uri.rootUrl ~ '/' ~ real_path) }) %}
<div class="hidden" data-file="{{ file|json_encode|e('html_attr') }}"></div>
{% endif %}
{% endmacro %}
{% import _self as macro %}
{% set defaults = config.plugins.form %}
{% set files = defaults.files|merge(field|default([])) %}
{% set limit = not field.multiple ? 1 : files.limit %}
{% do config.set('forms.dropzone.enabled', true) %}
{% block input %}
{% set page_can_upload = exists or (type == 'page' and not exists and not (field.destination starts with '@self' or field.destination starts with 'self@')) %}
{% set max_filesize = (field.filesize > form_max_filesize or field.filesize == 0) ? form_max_filesize : field.filesize %}
{% block prepend %}{% endblock %}
{% set settings = {name: field.name, paramName: (scope ~ field.name)|fieldName ~ (files.multiple ? '[]' : ''), limit: limit, filesize: max_filesize, accept: files.accept, resolution: files.resolution, resizeWidth: files.resizeWidth, resizeHeight: files.resizeHeight, resizeQuality: files.resizeQuality } %}
{% set dropzoneSettings = field.dropzone %}
{% set file_url_add = form.getFileUploadAjaxRoute().getUri() %}
{% set file_url_remove = form.getFileDeleteAjaxRoute(null, null).getUri() %}
<div class="{{ form_field_wrapper_classes ?: 'form-input-wrapper' }} {{ field.classes }} dropzone files-upload form-input-file {{ field.size }}"
data-grav-file-settings="{{ settings|json_encode|e('html_attr') }}"
data-dropzone-options="{{ dropzoneSettings|json_encode|e('html_attr') }}"
data-file-field-name="{{ field.name }}"
{% if file_url_add %}data-file-url-add="{{ file_url_add|e('html_attr') }}"{% endif %}
{% if file_url_remove %}data-file-url-remove="{{ file_url_remove|e('html_attr') }}"{% endif %}>
{% block file_extras %}{% endblock %}
<input
{# required attribute structures #}
{% block input_attributes %}
type="file"
{% if files.multiple %}multiple="multiple"{% endif %}
{% if files.accept %}accept="{{ files.accept|join(',') }}"{% endif %}
{% if field.disabled %}disabled="disabled"{% endif %}
{% if field.random_name %}random="true"{% endif %}
{% if required %}required="required"{% endif %}
{{ parent() }}
{% endblock %}
/>
{% for path, file in value %}
{{ macro.preview(path, file, _context) }}
{% endfor %}
{% include 'forms/fields/hidden/hidden.html.twig' with {field: {name: '_json.' ~ field.name}, value: (value ?? [])|json_encode } %}
</div>
{% if inline_errors and errors %}
<div class="{{ form_field_inline_error_classes }}">
<p class="form-message"><i class="fa fa-exclamation-circle"></i> {{ errors|first|raw }}</p>
</div>
{% endif %}
{% if form.xhr_submit %}
{% do assets.addJs('plugin://form/assets/dropzone-reinit.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 90 }) %}
{% endif %}
{% if grav.browser.browser == 'msie' and grav.browser.version < 12 %}
{% do assets.addJs('plugin://form/assets/object.assign.polyfill.js') %}
{% endif %}
{% do assets.addJs('jquery', 101) %}
{% do assets.addJs('plugin://form/assets/form.vendor.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 100 }) %}
{% do assets.addJs('plugin://form/assets/form.min.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 99 }) %}
{% do assets.addCss('plugin://form/assets/dropzone.min.css', { 'group': 'form'}) %}
{{ assets.css('form')|raw }}
{% do assets.addInlineJs("
window.GravForm = window.GravForm || {};
window.GravForm = Object.assign({}, window.GravForm, {
translations: {
PLUGIN_FORM: {
'DROPZONE_CANCEL_UPLOAD': " ~ 'PLUGIN_FORM.DROPZONE_CANCEL_UPLOAD'|t|json_encode ~ ",
'DROPZONE_CANCEL_UPLOAD_CONFIRMATION': " ~ 'PLUGIN_FORM.DROPZONE_CANCEL_UPLOAD_CONFIRMATION'|t|json_encode ~ ",
'DROPZONE_DEFAULT_MESSAGE': " ~ 'PLUGIN_FORM.DROPZONE_DEFAULT_MESSAGE'|t|json_encode ~ ",
'DROPZONE_FALLBACK_MESSAGE': " ~ 'PLUGIN_FORM.DROPZONE_FALLBACK_MESSAGE'|t|json_encode ~ ",
'DROPZONE_FALLBACK_TEXT': " ~ 'PLUGIN_FORM.DROPZONE_FALLBACK_TEXT'|t|json_encode ~ ",
'DROPZONE_FILE_TOO_BIG': " ~ 'PLUGIN_FORM.DROPZONE_FILE_TOO_BIG'|t|json_encode ~ ",
'DROPZONE_INVALID_FILE_TYPE': " ~ 'PLUGIN_FORM.DROPZONE_INVALID_FILE_TYPE'|t|json_encode ~ ",
'DROPZONE_MAX_FILES_EXCEEDED': " ~ 'PLUGIN_FORM.DROPZONE_MAX_FILES_EXCEEDED'|t|json_encode ~ ",
'DROPZONE_REMOVE_FILE': " ~ 'PLUGIN_FORM.DROPZONE_REMOVE_FILE'|t|json_encode ~ ",
'DROPZONE_REMOVE_FILE_CONFIRMATION': " ~ 'PLUGIN_FORM.DROPZONE_REMOVE_FILE_CONFIRMATION'|t|json_encode ~ ",
'DROPZONE_RESPONSE_ERROR': " ~ 'PLUGIN_FORM.DROPZONE_RESPONSE_ERROR'|t|json_encode ~ ",
'RESOLUTION_MIN': " ~ 'PLUGIN_FORM.RESOLUTION_MIN'|t|json_encode ~ ",
'RESOLUTION_MAX': " ~ 'PLUGIN_FORM.RESOLUTION_MAX'|t|json_encode ~ "
}
}
});
", {'group': 'bottom', 'position': 'before'}) %}
{% endblock %}
@@ -0,0 +1,128 @@
{% extends "forms/field.html.twig" %}
{% set defaults = config.plugins.form %}
{% set files = defaults.files|merge(field|default([])) %}
{% set limit = not field.multiple ? 1 : files.limit %}
{% block input %}
{% set page_can_upload = exists or (type == 'page' and not exists and not (field.destination starts with '@self' or field.destination starts with 'self@')) %}
{% set max_filesize = (field.filesize > form_max_filesize or field.filesize == 0) ? form_max_filesize : field.filesize %}
{% block prepend %}{% endblock %}
{% set settings = {name: field.name, paramName: (scope ~ field.name)|fieldName ~ (files.multiple ? '[]' : ''), limit: limit, filesize: max_filesize, accept: files.accept, resolution: files.resolution, resizeWidth: field.filepond.resize_width, resizeHeight: field.filepond.resize_height, resizeQuality: field.filepond.resize_quality } %}
{% set filepond_settings = field.filepond|default({}) %}
{% set file_url_add = form.getFileUploadAjaxRoute().getUri() %}
{% set file_url_remove = form.getFileDeleteAjaxRoute(null, null).getUri() %}
<div class="{{ form_field_wrapper_classes ?: 'form-input-wrapper' }} {{ field.classes }} filepond-root form-input-file {{ field.size }}"
data-grav-file-settings="{{ settings|json_encode|e('html_attr') }}"
data-filepond-options="{{ filepond_settings|json_encode|e('html_attr') }}"
data-file-field-name="{{ field.name }}"
{% if file_url_add %}data-file-url-add="{{ file_url_add|e('html_attr') }}"{% endif %}
{% if file_url_remove %}data-file-url-remove="{{ file_url_remove|e('html_attr') }}"{% endif %}>
{% block file_extras %}{% endblock %}
<input
{# required attribute structures #}
{% block input_attributes %}
type="file"
{% if files.multiple %}multiple="multiple"{% endif %}
{% if files.accept %}accept="{{ files.accept|join(',') }}"{% endif %}
{% if field.disabled %}disabled="disabled"{% endif %}
{% if field.random_name %}random="true"{% endif %}
{% if required %}required="required"{% endif %}
{{ parent() }}
{% endblock %}
/>
{% for path, file in value %}
<div class="hidden" data-file="{{ file|merge({remove: file.remove|default(''), path: file.path|default('')})|json_encode|e('html_attr') }}"></div>
{% endfor %}
{% include 'forms/fields/hidden/hidden.html.twig' with {field: {name: '_json.' ~ field.name}, value: (value ?? [])|json_encode } %}
</div>
{% if inline_errors and errors %}
<div class="{{ form_field_inline_error_classes }}">
<p class="form-message"><i class="fa fa-exclamation-circle"></i> {{ errors|first|raw }}</p>
</div>
{% endif %}
{% if grav.browser.browser == 'msie' and grav.browser.version < 12 %}
{% do assets.addJs('plugin://form/assets/object.assign.polyfill.js') %}
{% endif %}
{% do assets.addJs('jquery', 101) %}
{# FilePond core and plugins #}
{% do assets.addJs('plugin://form/assets/filepond/filepond.min.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 98 }) %}
{% do assets.addJs('plugin://form/assets/filepond/filepond-plugin-file-validate-size.min.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 97 }) %}
{% do assets.addJs('plugin://form/assets/filepond/filepond-plugin-file-validate-type.min.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 97 }) %}
{% do assets.addJs('plugin://form/assets/filepond/filepond-plugin-image-preview.min.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 97 }) %}
{% do assets.addJs('plugin://form/assets/filepond/filepond-plugin-image-resize.min.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 97 }) %}
{% do assets.addJs('plugin://form/assets/filepond/filepond-plugin-image-transform.min.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 97 }) %}
{# FilePond CSS #}
{% do assets.addCss('plugin://form/assets/filepond/filepond.min.css') %}
{% do assets.addCss('plugin://form/assets/filepond/filepond-plugin-image-preview.min.css') %}
{# Custom handlers - note: load this AFTER the libraries #}
{% do assets.addJs('plugin://form/assets/filepond-handler.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 96 }) %}
{# {% if form.xhr_submit %}#}
{# {% do assets.addJs('plugin://form/assets/filepond-reinit.js', { 'group': 'bottom', 'loading': 'defer', 'priority': 90 }) %}#}
{# {% endif %}#}
{% do assets.addInlineJs("
window.GravForm = window.GravForm || {};
window.GravForm = Object.assign({}, window.GravForm, {
translations: {
PLUGIN_FORM: {
'FILEPOND_REMOVE_FILE': " ~ 'PLUGIN_FORM.FILEPOND_REMOVE_FILE'|t|json_encode ~ ",
'FILEPOND_REMOVE_FILE_CONFIRMATION': " ~ 'PLUGIN_FORM.FILEPOND_REMOVE_FILE_CONFIRMATION'|t|json_encode ~ ",
'FILEPOND_CANCEL_UPLOAD': " ~ 'PLUGIN_FORM.FILEPOND_CANCEL_UPLOAD'|t|json_encode ~ ",
'FILEPOND_ERROR_FILESIZE': " ~ 'PLUGIN_FORM.FILEPOND_ERROR_FILESIZE'|t|json_encode ~ ",
'FILEPOND_ERROR_FILETYPE': " ~ 'PLUGIN_FORM.FILEPOND_ERROR_FILETYPE'|t|json_encode ~ "
}
}
});
", {'group': 'bottom', 'position': 'before'}) %}
{% do assets.addInlineJs("
document.addEventListener('DOMContentLoaded', function() {
if (typeof GravFormXHR !== 'undefined') {
// First check if DOM property exists
if (GravFormXHR.DOM && typeof GravFormXHR.DOM.updateFormContent === 'function') {
var originalUpdateFormContent = GravFormXHR.DOM.updateFormContent;
GravFormXHR.DOM.updateFormContent = function() {
var result = originalUpdateFormContent.apply(this, arguments);
// Dispatch event after form content is updated
setTimeout(function() {
document.dispatchEvent(new Event('grav-form-updated'));
if (window.reinitializeFilePonds) {
window.reinitializeFilePonds();
}
}, 50);
return result;
};
}
// If DOM property doesn't exist, try to hook into submit directly
else if (typeof GravFormXHR.submit === 'function') {
var originalSubmit = GravFormXHR.submit;
GravFormXHR.submit = function(form) {
var result = originalSubmit.apply(this, arguments);
// Reinitialize FilePond after form submission
setTimeout(function() {
document.dispatchEvent(new Event('grav-form-updated'));
if (window.reinitializeFilePonds) {
window.reinitializeFilePonds();
}
}, 500);
return result;
};
}
}
});
", {'group': 'bottom'}) %}
{% endblock %}
@@ -0,0 +1,5 @@
{% extends "forms/field.html.twig" %}
{% block field %}
<input type="hidden" name="__form-name__" value="{{ form.name }}" />
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "forms/field.html.twig" %}
{% block field %}
{% if form.task %}
<input type="hidden" name="task" value="{{ form.task }}" />
{% endif %}
{% endblock %}
@@ -0,0 +1,15 @@
{% extends "forms/field.html.twig" %}
{% block field %}
{# Used if the field is being used directly outside of form #}
{% set value = value ?? field.value ?? (field.evaluate ? evaluate(field.default) : field.default) %}
{# Evaluate support for the form #}
{% if not has_value and value and field.evaluate %}
{% set value = evaluate(value) %}
{% endif %}
{% set input_value = value is iterable ? value|join(',') : value|string %}
<input data-grav-field="hidden" data-grav-disabled="false" {% if field.id is defined %}id="{{ field.id|e }}" {% endif %}type="hidden" class="input" name="{{ (scope ~ field.name)|fieldName }}" value="{{ input_value|e('html_attr') }}" />
{% endblock %}
@@ -0,0 +1,15 @@
{% extends "forms/field.html.twig" %}
{% block field %}
{% set input_value = value is iterable ? value|join(',') : value|string %}
<input aria-hidden="true"
type="text"
{% if config.plugins.form.inline_css == true %}
style="visibility:hidden;position:absolute!important;height:1px;width:1px;overflow:hidden;clip:rect(1px,1px,1px,1px);"
{% endif %}
class="form-honeybear"
tabindex="-1"
autocomplete="off"
name="{{ (scope ~ field.name)|fieldName }}"
value="{{ input_value|e }}" />
{% endblock %}
@@ -0,0 +1,28 @@
{% extends "forms/field.html.twig" %}
{% block input %}
<div class="form-input-wrapper {{ field.size }}">
{% set input_value = value is iterable ? value|join(',') : value|string %}
<input
type="text"
value="{{ input_value|e }}"
data-key-observe="{{ (scope ~ field.name)|fieldName }}"
{% block input_attributes %}
{% if field.classes is defined %}class="{{ field.classes }}" {% endif %}
{% if field.id is defined %}id="{{ field.id|e }}" {% endif %}
{% if field.style is defined %}style="{{ field.style|e }}" {% endif %}
{% if field.disabled or isDisabledToggleable %}disabled="disabled"{% endif %}
{% if field.placeholder %}placeholder="{{ field.placeholder }}"{% endif %}
{% if field.autofocus in ['on', 'true', 1] %}autofocus="autofocus"{% endif %}
{% if field.novalidate in ['on', 'true', 1] %}novalidate="novalidate"{% endif %}
{% if field.readonly in ['on', 'true', 1] %}readonly="readonly"{% endif %}
{% if field.autocomplete in ['on', 'off'] %}autocomplete="{{ field.autocomplete }}"{% endif %}
{% if field.tabindex %}tabindex="{{ field.tabindex }}"{% endif %}
{% if field.validate.required in ['on', 'true', 1] %}required="required"{% endif %}
{% if field.validate.pattern %}pattern="{{ field.validate.pattern }}"{% endif %}
{% if field.validate.message %}title="{{ field.validate.message|e|t }}"
{% elseif field.title is defined %}title="{{ field.title|e|t }}" {% endif %}
{% endblock %}
/>
</div>
{% endblock %}
@@ -0,0 +1,6 @@
{% extends "forms/field.html.twig" %}
{% block input_attributes %}
type="month"
{{ parent() }}
{% endblock %}
@@ -0,0 +1,4 @@
{% set nonce_action = form.getNonceAction() ?? 'form' %}
{% set nonce_name = form.getNonceName() ?? 'form-nonce' %}
{% set nonce_value = form.getNonce() %}
<input type="hidden" name="{{ nonce_name }}" value="{{ nonce_value }}" class="form-nonce-field" data-nonce-action="{{ nonce_action }}" />
@@ -0,0 +1,23 @@
{% extends "forms/fields/text/text.html.twig" %}
{% block input_attributes %}
type="number"
{% if field.validate.min is defined %}min="{{ field.validate.min }}"{% endif %}
{% if field.validate.max is defined %}max="{{ field.validate.max }}"{% endif %}
{% if field.validate.step is defined %}step="{{ field.validate.step }}"{% endif %}
{# Skip text.html.twig's minlength/maxlength and go directly to field.html.twig #}
{% if field.size %}size="{{ field.size }}"{% endif %}
{% if field.classes is defined %}class="{{ field.classes }}" {% endif %}
{% if field.id is defined %}id="{{ field.id }}" {% endif %}
{% if field.style is defined %}style="{{ field.style }}" {% endif %}
{% if field.disabled or isDisabledToggleable %}disabled="disabled"{% endif %}
{% if field.placeholder %}placeholder="{{ field.placeholder|t }}"{% endif %}
{% if field.autofocus in ['on', 'true', 1] %}autofocus="autofocus"{% endif %}
{% if field.novalidate in ['on', 'true', 1] %}novalidate="novalidate"{% endif %}
{% if field.readonly in ['on', 'true', 1] %}readonly="readonly"{% endif %}
{% if field.autocomplete is defined %}autocomplete="{{ field.autocomplete }}"{% endif %}
{% if field.validate.required in ['on', 'true', 1] %}required="required"{% endif %}
{% if field.validate.pattern %}pattern="{{ field.validate.pattern }}"{% endif %}
{% if field.validate.message %}title="{{ field.validate.message|t }}"
{% elseif field.title is defined %}title="{{ field.title|t }}" {% endif %}
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "forms/field.html.twig" %}
{% set value = null %}
{% block input_attributes %}
type="password"
{{ parent() }}
{% endblock %}
@@ -0,0 +1,21 @@
{% extends "forms/field.html.twig" %}
{% block input %}
{% for key, text in field.options %}
{% set id = field.id|default(field.name) ~ '-' ~ key %}
<div class="radio {{ form_field_wrapper_classes }} {{ field.wrapper_classes }}">
<input type="radio"
value="{{ key|e }}"
id="{{ id|e }}"
name="{{ (scope ~ field.name)|fieldName }}"
class="{{ form_field_radio_classes }} {{ field.classes }}"
{% if key == value %}checked="checked" {% endif %}
{% if field.disabled or isDisabledToggleable %}disabled="disabled"{% endif %}
{% if required %}required="required"{% endif %}
{% if field.tabindex %}tabindex="{{ field.tabindex }}"{% endif %}
/>
<label style="display: inline" class="inline" for="{{ id|e }}">{{ text|t|raw }}</label>
</div>
{% endfor %}
{% endblock %}
@@ -0,0 +1,9 @@
{% extends "forms/field.html.twig" %}
{% block input_attributes %}
type="range"
{% if field.validate.min %}min="{{ field.validate.min }}"{% endif %}
{% if field.validate.max %}max="{{ field.validate.max }}"{% endif %}
{% if field.validate.step %}step="{{ field.validate.step }}"{% endif %}
{{ parent() }}
{% endblock %}
@@ -0,0 +1,393 @@
{% extends "forms/field.html.twig" %}
{% block label %}{% endblock %}
{% block input %}
{% set config = grav.config %}
{% set formId = form.id ?: form.name %}
{% set callbackId = formId|underscorize %}
{% set lang = grav.language.language %}
{# Get configuration values with fallbacks #}
{% set version = field.recaptcha_version ?? config.plugins.form.recaptcha.version ?? '2-checkbox' %}
{% set site_key = field.recaptcha_site_key ?? config.plugins.form.recaptcha.site_key %}
{% set theme = field.recaptcha_theme ?? config.plugins.form.recaptcha.theme ?? 'light' %}
{% if not site_key %}
<div class="form-error">reCAPTCHA site key is not set. Please set it in the form field or plugin configuration.</div>
{% else %}
{% if version == 3 or version == '3' %}
{# --- reCAPTCHA v3 Handling --- #}
{% set action = (page.route|trim('/') ~ '-' ~ form.name)|underscorize|md5 %}
<div class="g-recaptcha-container"
data-form-id="{{ formId }}"
data-recaptcha-version="3"
data-captcha-provider="recaptcha"
data-intercepts-submit="true"
data-sitekey="{{ site_key }}"
data-version="3"
data-action="{{ action }}"
data-theme="{{ theme }}"
data-lang="{{ lang }}"
data-callback-id="{{ callbackId }}">
{# Container for v3 - will be managed by JS #}
<input type="hidden" name="data[token]" value="">
<input type="hidden" name="data[action]" value="{{ action }}">
</div>
{% do assets.addJs('https://www.google.com/recaptcha/api.js?render=' ~ site_key, { group: 'bottom' }) %}
<script>
(function() {
window.GravRecaptchaInitializers = window.GravRecaptchaInitializers || {};
function addHiddenInput(form, name, value) {
const existing = form.querySelector('input[type="hidden"][name="' + name + '"]');
if (existing) {
existing.value = value;
} else {
const input = document.createElement('input');
input.setAttribute('type', 'hidden');
input.setAttribute('name', name);
input.setAttribute('value', value);
form.insertBefore(input, form.firstChild);
}
}
function initRecaptchaV3(container) {
const formId = container.dataset.formId;
const siteKey = container.dataset.sitekey;
const action = container.dataset.action;
const form = document.getElementById(formId);
if (!form) return;
console.log(`Initializing reCAPTCHA v3 for form ${formId}`);
const submitHandler = function(event) {
event.preventDefault();
console.log(`reCAPTCHA v3 intercepting submit for form ${formId}`);
grecaptcha.ready(function() {
grecaptcha.execute(siteKey, { action: action })
.then(function(token) {
console.log(`reCAPTCHA v3 token received for form ${formId}`);
addHiddenInput(form, 'data[token]', token);
addHiddenInput(form, 'data[action]', action);
form.removeEventListener('submit', submitHandler);
if (form.dataset.xhrEnabled === 'true' && window.GravFormXHR && typeof window.GravFormXHR.submit === 'function') {
window.GravFormXHR.submit(form);
} else {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
}
setTimeout(() => {
const currentForm = document.getElementById(formId);
if (currentForm && !currentForm.dataset.recaptchaListenerAttached) {
currentForm.addEventListener('submit', submitHandler);
currentForm.dataset.recaptchaListenerAttached = 'true';
} else if (currentForm) {
delete currentForm.dataset.recaptchaListenerAttached;
}
}, 0);
});
});
};
delete form.dataset.recaptchaListenerAttached;
if (!form.dataset.recaptchaListenerAttached) {
form.addEventListener('submit', submitHandler);
form.dataset.recaptchaListenerAttached = 'true';
}
}
// Register the initializer function
const initializerFunctionName = 'initRecaptcha_{{ formId }}';
window.GravRecaptchaInitializers[initializerFunctionName] = function() {
const container = document.querySelector('[data-form-id="{{ formId }}"][data-captcha-provider="recaptcha"]');
if (!container) return;
initRecaptchaV3(container);
};
// Initial call
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', window.GravRecaptchaInitializers[initializerFunctionName]);
} else {
setTimeout(window.GravRecaptchaInitializers[initializerFunctionName], 0);
}
})();
</script>
{% elseif version == '2-invisible' %}
{# --- reCAPTCHA v2 Invisible Handling --- #}
<div class="g-recaptcha-container"
data-form-id="{{ formId }}"
data-recaptcha-version="2-invisible"
data-captcha-provider="recaptcha"
data-intercepts-submit="true"
data-sitekey="{{ site_key }}"
data-version="2-invisible"
data-theme="{{ theme }}"
data-lang="{{ lang }}"
data-callback-id="{{ callbackId }}">
{# Container for v2 invisible - will be managed by JS #}
<div id="g-recaptcha-{{ formId }}" class="g-recaptcha"></div>
</div>
<script>
(function() {
window.GravRecaptchaInitializers = window.GravRecaptchaInitializers || {};
function addHiddenInput(form, name, value) {
const existing = form.querySelector('input[type="hidden"][name="' + name + '"]');
if (existing) {
existing.value = value;
} else {
const input = document.createElement('input');
input.setAttribute('type', 'hidden');
input.setAttribute('name', name);
input.setAttribute('value', value);
form.insertBefore(input, form.firstChild);
}
}
function initRecaptchaV2Invisible(container) {
const formId = container.dataset.formId;
const siteKey = container.dataset.sitekey;
const lang = container.dataset.lang;
const theme = container.dataset.theme;
const callbackId = container.dataset.callbackId || formId;
const form = document.getElementById(formId);
let widgetId = null;
if (!form) return;
console.log(`Initializing reCAPTCHA v2 Invisible for form ${formId}`);
const callbackName = 'captchaInvisibleOnloadCallback_' + callbackId;
if (typeof window[callbackName] !== 'function') {
window[callbackName] = function() {
console.log('reCAPTCHA Invisible API ready for form ' + formId);
};
if (!document.querySelector('script[src*="recaptcha/api.js?onload=' + callbackName + '"]')) {
const script = document.createElement('script');
script.src = 'https://www.google.com/recaptcha/api.js?onload=' + callbackName + '&hl=' + lang + '&theme=' + theme;
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
}
const submitHandler = function(event) {
event.preventDefault();
console.log(`reCAPTCHA v2 Invisible intercepting submit for form ${formId}`);
if (typeof grecaptcha === 'undefined' || typeof grecaptcha.render === 'undefined') {
console.error('grecaptcha not ready for invisible captcha');
return;
}
const recaptchaId = 'g-recaptcha-' + formId;
let captchaElement = document.getElementById(recaptchaId);
if (!captchaElement) {
captchaElement = document.createElement('div');
captchaElement.setAttribute('id', recaptchaId);
captchaElement.className = 'g-recaptcha';
form.appendChild(captchaElement);
}
const renderCaptcha = () => {
if (widgetId !== null) {
try {
grecaptcha.reset(widgetId);
} catch (e) {
console.warn("Error resetting captcha", e);
}
}
widgetId = grecaptcha.render(recaptchaId, {
sitekey: siteKey,
size: 'invisible',
callback: function(token) {
console.log(`reCAPTCHA v2 Invisible token received for form ${formId}`);
addHiddenInput(form, 'g-recaptcha-response', token);
form.removeEventListener('submit', submitHandler);
if (form.dataset.xhrEnabled === 'true' && window.GravFormXHR && typeof window.GravFormXHR.submit === 'function') {
window.GravFormXHR.submit(form);
} else {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
}
setTimeout(() => {
const currentForm = document.getElementById(formId);
if (currentForm && !currentForm.dataset.recaptchaListenerAttached) {
currentForm.addEventListener('submit', submitHandler);
currentForm.dataset.recaptchaListenerAttached = 'true';
} else if (currentForm) {
delete currentForm.dataset.recaptchaListenerAttached;
}
}, 0);
}
});
grecaptcha.execute(widgetId);
};
if (typeof grecaptcha !== 'undefined' && grecaptcha.render) {
renderCaptcha();
} else {
const originalOnload = window[callbackName];
window[callbackName] = function() {
if(originalOnload) originalOnload();
renderCaptcha();
};
console.warn("grecaptcha object not found immediately, waiting for onload callback: " + callbackName);
}
};
delete form.dataset.recaptchaListenerAttached;
if (!form.dataset.recaptchaListenerAttached) {
form.addEventListener('submit', submitHandler);
form.dataset.recaptchaListenerAttached = 'true';
}
}
// Register the initializer function
const initializerFunctionName = 'initRecaptcha_{{ formId }}';
window.GravRecaptchaInitializers[initializerFunctionName] = function() {
const container = document.querySelector('[data-form-id="{{ formId }}"][data-captcha-provider="recaptcha"]');
if (!container) return;
initRecaptchaV2Invisible(container);
};
// Initial call
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', window.GravRecaptchaInitializers[initializerFunctionName]);
} else {
setTimeout(window.GravRecaptchaInitializers[initializerFunctionName], 0);
}
})();
</script>
{% else %}
{# --- reCAPTCHA v2 Checkbox Handling --- #}
{# Add script and container #}
{% set container_id = 'g-recaptcha-' ~ formId %}
{% set onloadCallback = 'captchaCheckboxOnloadCallback_' ~ callbackId %}
<div class="g-recaptcha-container"
data-form-id="{{ formId }}"
data-captcha-provider="recaptcha"
data-sitekey="{{ site_key }}"
data-version="2-checkbox"
data-theme="{{ theme }}"
data-lang="{{ lang }}"
data-callback-id="{{ callbackId }}">
<div id="{{ container_id }}" class="g-recaptcha"></div>
</div>
{% do assets.addJs('https://www.google.com/recaptcha/api.js?onload=' ~ onloadCallback ~ '&render=explicit', { 'group': 'bottom', 'loading': 'defer' }) %}
<script>
(function() {
// Explicit rendering for reCAPTCHA v2 Checkbox
window.GravExplicitCaptchaInitializers = window.GravExplicitCaptchaInitializers || {};
const initializerFunctionName = 'initExplicitCaptcha_{{ formId }}';
// Define the initializer function
window.GravExplicitCaptchaInitializers[initializerFunctionName] = function() {
const containerId = '{{ container_id }}';
const container = document.getElementById(containerId);
if (!container) {
console.warn('reCAPTCHA container #' + containerId + ' not found.');
return;
}
// Prevent re-rendering if widget already exists
if (container.innerHTML.trim() !== '' && container.querySelector('iframe')) {
return;
}
// Get configuration from parent container
const parentContainer = container.closest('.g-recaptcha-container');
if (!parentContainer) {
console.error('Cannot find parent container for #' + containerId);
return;
}
const sitekey = parentContainer.dataset.sitekey;
const theme = parentContainer.dataset.theme;
if (!sitekey) {
console.error('reCAPTCHA sitekey missing for #' + containerId);
return;
}
console.log('Attempting to render reCAPTCHA in #' + containerId);
if (typeof grecaptcha !== 'undefined' && typeof grecaptcha.render === 'function') {
try {
grecaptcha.render(containerId, {
sitekey: sitekey,
theme: theme,
callback: function(token) {
console.log('reCAPTCHA challenge successful for #' + containerId);
}
});
} catch (e) {
console.error('Error calling grecaptcha.render for #' + containerId, e);
container.innerHTML = '<p style="color:red;">Error initializing reCAPTCHA.</p>';
}
} else {
console.warn('grecaptcha API not available yet for #' + containerId + '. Waiting for onload.');
}
};
// Define the global onload callback
window['{{ onloadCallback }}'] = function() {
console.log('reCAPTCHA API loaded, triggering init for #{{ container_id }}');
if (window.GravExplicitCaptchaInitializers[initializerFunctionName]) {
window.GravExplicitCaptchaInitializers[initializerFunctionName]();
} else {
console.error("Initializer " + initializerFunctionName + " not found!");
}
};
// Form submit handler to check if captcha is completed
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('{{ formId }}');
if (form) {
form.addEventListener('submit', function(event) {
const response = grecaptcha.getResponse();
if (!response) {
event.preventDefault();
alert("{{ field.captcha_not_validated|t|default('Please complete the captcha')|e('js') }}");
} else if (form.dataset.xhrEnabled === 'true' && window.GravFormXHR && typeof window.GravFormXHR.submit === 'function') {
event.preventDefault();
window.GravFormXHR.submit(form);
}
});
}
});
})();
</script>
{% endif %}
{% endif %}
{% endblock %}
@@ -0,0 +1,21 @@
{% extends "forms/field.html.twig" %}
{% set title_level = grav['admin'] is defined ? 'h1' : field.title_level|default('h3') %}
{% block field %}
{% if field.security is empty or authorize(array(field.security)) %}
<div class="{{ field.classes }}">
{% if field.title or field.underline %}
<{{ title_level }} class="{% if not field.underline %}no_underline{% endif %}">{{ field.title|t }}</{{ title_level }}>
{% endif %}
{% if field.text %}
<p>{{ field.text|t|raw }}</p>
{% endif %}
{% embed 'forms/default/fields.html.twig' with {name: field.name, fields: field.fields} %}
{% block outer_markup_field_open %}<div class="form-section">{% endblock %}
{% block outer_markup_field_close %}</div>{% endblock %}
{% endembed %}
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,87 @@
{% extends "forms/field.html.twig" %}
{% block global_attributes %}
data-grav-selectize="{{ (field.selectize is defined ? field.selectize : {})|json_encode()|e('html_attr') }}"
{{ parent() }}
{% endblock %}
{% block input %}
<div class="{{ form_field_wrapper_classes ?: 'form-select-wrapper' }} {{ field.size }} {{ field.wrapper_classes }}">
<select name="{{ (scope ~ field.name)|fieldName ~ (field.multiple ? '[]' : '') }}"
class="{{ form_field_select_classes }} {{ field.classes }} {{ field.size }}"
{% if field.id is defined %}id="{{ field.id|e }}" {% endif %}
{% if field.style is defined %}style="{{ field.style|e }}" {% endif %}
{% if field.disabled %}disabled="disabled"{% endif %}
{% if field.autofocus in ['on', 'true', 1] %}autofocus="autofocus"{% endif %}
{% if field.novalidate in ['on', 'true', 1] %}novalidate="novalidate"{% endif %}
{% if required %}required="required"{% endif %}
{% if field.multiple in ['on', 'true', 1] %}multiple="multiple"{% endif %}
{% if field.disabled or isDisabledToggleable %}disabled="disabled"{% endif %}
{% if field.tabindex %}tabindex="{{ field.tabindex }}"{% endif %}
{% if field.form %}form="{{ field.form }}"{% endif %}
{% if field.autocomplete is defined %}autocomplete="{{ field.autocomplete }}"{% endif %}
{% if field.key %}
data-key-observe="{{ (scope ~ field.name)|fieldName }}"
{% endif %}
{% if field.datasets %}
{% for datakey, datavalue in field.datasets %}
data-{{ datakey }}="{{ datavalue|e('html_attr') }}"
{% endfor %}
{% endif %}
{% if field.attributes %}
{% for key, value in field.attributes %}
{{ key }}="{{ value|e('html_attr') }}"
{% endfor %}
{% endif %}
>
{% if field.placeholder %}<option value="" disabled selected>{{ field.placeholder|t }}</option>{% endif %}
{% set options = field.options %}
{% if field.selectize.create and value %}
{% set custom_value = field.multiple ? value : { (value): value } %}
{% set options = options|merge(custom_value|default([]))|array_unique %}
{% endif %}
{# When selectize+multiple, the legacy default stores option labels
rather than keys. Setting `selectize.store_keys: true` opts in to
the saner "store the option key as value" behavior. #}
{% set selectize_store_keys = field.selectize.store_keys ?? false %}
{% set selectize_use_label_as_value = field.selectize and field.multiple and not selectize_store_keys %}
{% set value = value is iterable ? value : value|string %}
{% for key, item_value in options %}
{% if item_value is iterable and item_value.value %}
{% set akey = selectize_use_label_as_value ? item_value : key %}
{% set avalue = item_value.value|t %}
<option {{ item_value.disabled ? 'disabled="disabled"' : '' }}
{{ item_value.selected or key == value ? 'selected="selected"' : '' }}
{{ item_value.label ? 'label=' ~ item_value.label : '' }}
value="{{ akey }}"
>
{# GHSA-c2q3-p4jr-c55f: dropped |raw — option text is now
autoescaped so taxonomy/option values supplied by
lower-privileged editors can no longer inject script. #}
{{ avalue }}
</option>
{% elseif item_value is iterable %}
{% set optgroup_label = item_value|keys|first %}
<optgroup label="{{ optgroup_label|t|e('html_attr') }}">
{% for subkey, suboption in field.options[key][optgroup_label] %}
{% set subkey = subkey|string %}
{% set item_value = (selectize_use_label_as_value ? suboption : subkey)|string %}
{% set selected = (selectize_use_label_as_value ? suboption : subkey)|string %}
<option {% if subkey is same as (value) or (field.multiple and selected in value) %}selected="selected"{% endif %} value="{{ subkey }}">
{{ suboption|t }}
</option>
{% endfor %}
</optgroup>
{% else %}
{% set val = (selectize_use_label_as_value ? item_value : key)|string %}
{% set selected = (selectize_use_label_as_value ? item_value : key)|string %}
<option {% if val is same as (value) or (field.multiple and selected in value) %}selected="selected"{% endif %} value="{{ val }}">{{ item_value|t }}</option>
{% endif %}
{% endfor %}
</select>
</div>
{% endblock %}
@@ -0,0 +1,2 @@
{# Deprecated Form 4.0: Just use `select` field #}
{% extends "forms/fields/select/select.html.twig" %}
@@ -0,0 +1,105 @@
{% extends "forms/field.html.twig" %}
{% block prepend %}
<div id="signature-pad" class="signature-pad">
<div class="signature-pad--body">
<canvas></canvas>
</div>
<div class="signature-pad--footer">
<div class="description">Sign above</div>
<div class="signature-pad--actions">
<div>
<button type="button" class="btn btn-primary-border" data-action="clear">Clear Signature</button>
</div>
</div>
</div>
</div>
<script src="{{ url('plugin://form/assets/signature_pad.js') }}"></script>
<script>
var wrapper = document.getElementById("signature-pad");
var clearButton = wrapper.querySelector("[data-action=clear]");
var canvas = wrapper.querySelector("canvas");
var signaturePad = new SignaturePad(canvas, {
// It's Necessary to use an opaque color when saving image as JPEG;
// this option can be omitted if only saving as PNG or SVG
backgroundColor: 'rgb(255, 255, 255)',
onEnd: function() {
var input = document.querySelector('[name="data[{{ field.name }}]"]');
input.value = this.toDataURL();
}
});
// Adjust canvas coordinate space taking into account pixel ratio,
// to make it look crisp on mobile devices.
// This also causes canvas to be cleared.
function resizeCanvas() {
// When zoomed out to less than 100%, for some very strange reason,
// some browsers report devicePixelRatio as less than 1
// and only part of the canvas is cleared then.
var ratio = Math.max(window.devicePixelRatio || 1, 1);
// This part causes the canvas to be cleared
canvas.width = canvas.offsetWidth * ratio;
canvas.height = canvas.offsetHeight * ratio;
canvas.getContext("2d").scale(ratio, ratio);
// This library does not listen for canvas changes, so after the canvas is automatically
// cleared by the browser, SignaturePad#isEmpty might still return false, even though the
// canvas looks empty, because the internal data of this library wasn't cleared. To make sure
// that the state of this library is consistent with visual state of the canvas, you
// have to clear it manually.
signaturePad.clear();
}
// On mobile devices it might make more sense to listen to orientation change,
// rather than window resize events.
window.onresize = resizeCanvas;
resizeCanvas();
function download(dataURL, filename) {
var blob = dataURLToBlob(dataURL);
var url = window.URL.createObjectURL(blob);
var a = document.createElement("a");
a.style = "display: none";
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
}
// One could simply use Canvas#toBlob method instead, but it's just to show
// that it can be done using result of SignaturePad#toDataURL.
function dataURLToBlob(dataURL) {
// Code taken from https://github.com/ebidel/filer.js
var parts = dataURL.split(';base64,');
var contentType = parts[0].split(":")[1];
var raw = window.atob(parts[1]);
var rawLength = raw.length;
var uInt8Array = new Uint8Array(rawLength);
for (var i = 0; i < rawLength; ++i) {
uInt8Array[i] = raw.charCodeAt(i);
}
return new Blob([uInt8Array], { type: contentType });
}
clearButton.addEventListener("click", function (event) {
signaturePad.clear();
});
</script>
{% endblock prepend %}
{% block input_attributes %}
type="hidden"
{{ parent() }}
{% endblock %}
@@ -0,0 +1,19 @@
{% extends "forms/field.html.twig" %}
{% block field %}
<div class="form-field form-spacer {{ field.classes }}">
{% if field.title %}
<{{ field.title_type|default('h3') }}>{{- field.title|t|raw -}}</{{ field.title_type|default('h3') }}>
{% endif %}
{% if field.markdown %}
{{- field.text|t|markdown|raw -}}
{% else %}
<p>{{- field.text|t|raw -}}</p>
{% endif %}
{% if field.underline %}
<hr />
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1 @@
{% extends 'forms/fields/checkbox/checkbox.html.twig' %}
@@ -0,0 +1,8 @@
{% extends "forms/field.html.twig" %}
{% block field %}
{% embed 'forms/default/fields.html.twig' with {name: field.name, fields: field.fields} %}
{% block outer_markup_field_open %}<div class="form-tab">{% endblock %}
{% block outer_markup_field_close %}</div>{% endblock %}
{% endembed %}
{% endblock %}
@@ -0,0 +1,60 @@
{% extends "forms/field.html.twig" %}
{% if not grav.admin %}
{% do assets.addJs('plugin://form/assets/form.vendor.js', { 'group': 'bottom', 'loading': 'defer' }) %}
{% do assets.addJs('plugin://form/assets/form.min.js', { 'group': 'bottom', 'loading': 'defer' }) %}
{% endif %}
{% block field %}
<div class="form-tabs {{ field.class }} {{ field.classes }}">
{% set fields = prepare_form_fields(field.fields, field.name) %}
{% if fields|length %}
{% set tabs = {} %}
{% for tab in fields %}
{% if tab.type == 'tab' and not tab.validate.ignore and (tab.security is empty or authorize(array(tab.security))) %}
{% set tabs = tabs|merge([tab]) %}
{% endif %}
{% endfor %}
{% set count = tabs|length %}
{% if count == 0 %}
{# Nothing to display #}
{% elseif count == 1 and not admin %}
{% set fields = (tabs|first).fields %}
{% for field in fields %}
{% set value = field.name ? (form ? form.value(field.name) : data.value(field.name)) : data.toArray %}
{% set field_templates = include_form_field(field.type, field_layout, 'text') %}
{% include field_templates %}
{% endfor %}
{% else %}
{% set tabsKey = form.name ~ '-' ~ fields|keys|join(':')|md5 %}
{% set storedValue = grav.admin ? get_cookie('grav-tabs-state')|default('{}')|json_decode : [] %}
{% set storedTab = attribute(storedValue, 'tab-' ~ tabsKey) %}
{% if storedTab is empty %}
{% set active = uri.params.tab ?? field.active ?? 1 %}
{% endif %}
<div class="tabs-nav">
{% for tab in tabs %}
{% if tab.type == 'tab' and (tab.condition is null or tab.condition == true) %}
<a class="tab__link {{ (storedTab == scope ~ tab.name) or active == loop.index ? 'active' : '' }}" data-tabid="tab-{{ tabsKey ~ '-' ~ tab.name }}" data-tabkey="tab-{{ tabsKey }}" data-scope="{{ scope ~ tab.name }}">
<span>{{ tab.title|t }}</span>
{% endif %}
</a>
{% endfor %}
</div>
<div class="tabs-content">
{% embed 'forms/default/fields.html.twig' with {name: field.name, fields: fields} %}
{% block inner_markup_field_open %}
<div id="tab-{{ tabsKey ~ '-' ~ field.name }}" class="tab__content {{ (storedTab == scope ~ field.name) or active == loop.index ? 'active' : '' }}">
{% endblock %}
{% block inner_markup_field_close %}
</div>
{% endblock %}
{% endembed %}
</div>
{% endif %}
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,9 @@
{% extends "forms/field.html.twig" %}
{% block input_attributes %}
type="tel"
{% if field.size %}size="{{ field.size }}"{% endif %}
{% if field.minlength is defined or field.validate.min is defined %}minlength="{{ field.minlength | default(field.validate.min) }}"{% endif %}
{% if field.maxlength is defined or field.validate.max is defined %}maxlength="{{ field.maxlength | default(field.validate.max) }}"{% endif %}
{{ parent() }}
{% endblock %}
@@ -0,0 +1,7 @@
{% set value = iterable ? value|join(', ') : value|string %}
{% if link -%}
<a href="{{ link|e }}">{{ value|e }}</a>
{%- else -%}
{{ value|e }}
{%- endif %}
@@ -0,0 +1,39 @@
{% if field.prepend or field.append or field.copy_to_clipboard %}
{% set field = field|merge({'wrapper_classes': 'form-input-addon-wrapper'}) %}
{% endif %}
{% extends "forms/field.html.twig" %}
{% block prepend %}
{% if field.prepend %}
<div class="form-input-addon form-input-prepend">
{{- field.prepend|t|raw -}}
</div>
{% endif %}
{% endblock %}
{% block input_attributes %}
type="text"
{% if field.size %}size="{{ field.size }}"{% endif %}
{% if field.minlength is defined or field.validate.min is defined %}minlength="{{ field.minlength | default(field.validate.min) }}"{% endif %}
{% if field.maxlength is defined or field.validate.max is defined %}maxlength="{{ field.maxlength | default(field.validate.max) }}"{% endif %}
{{ parent() }}
{% endblock %}
{% block append %}
{% if field.copy_to_clipboard %}
<div class="form-input-addon form-input-append copy-to-clipboard">
{% if field.copy_to_clipboard in ['0', '1'] %}
<i class="fa fa-clipboard"></i>
{% else %}
{{- field.copy_to_clipboard|t|raw -}}
{% endif %}
</div>
{% elseif field.append %}
<div class="form-input-addon form-input-append">
{{- field.append|t|raw -}}
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,51 @@
{% extends "forms/field.html.twig" %}
{% block input %}
<div class="{{ form_field_wrapper_classes ?: 'form-textarea-wrapper' }} {{ field.size }} {{ field.wrapper_classes }}">
{% block prepend %}{% endblock prepend %}
<textarea
{# required attribute structures #}
name="{{ (scope ~ field.name)|fieldName }}"
{# input attribute structures #}
{% block input_attributes %}
class="{{ form_field_textarea_classes }} {{ field.classes }} {{ field.size }}"
{% if field.id is defined %}id="{{ field.id|e }}" {% endif %}
{% if field.style is defined %}style="{{ field.style|e }}" {% endif %}
{% if field.disabled or isDisabledToggleable %}disabled="disabled"{% endif %}
{% if field.placeholder %}placeholder="{{ field.placeholder|t }}"{% endif %}
{% if field.autofocus in ['on', 'true', 1] %}autofocus="autofocus"{% endif %}
{% if field.novalidate in ['on', 'true', 1] %}novalidate="novalidate"{% endif %}
{% if field.readonly in ['on', 'true', 1] %}readonly="readonly"{% endif %}
{% if field.autocomplete in ['on', 'off'] %}autocomplete="{{ field.autocomplete }}"{% endif %}
{% if field.tabindex %}tabindex="{{ field.tabindex }}"{% endif %}
{% if required %}required="required"{% endif %}
{% if field.validate.pattern %}pattern="{{ field.validate.pattern }}"{% endif %}
{% if field.validate.message %}title="{{ field.validate.message|t|e }}"{% endif %}
{% if field.rows is defined %}rows="{{ field.rows }}"{% endif %}
{% if field.cols is defined %}cols="{{ field.cols }}"{% endif %}
{% if field.minlength is defined or field.validate.min is defined %}minlength="{{ field.minlength | default(field.validate.min) }}"{% endif %}
{% if field.maxlength is defined or field.validate.max is defined %}maxlength="{{ field.maxlength | default(field.validate.max) }}"{% endif %}
{% if field.datasets %}
{% for datakey, datavalue in field.datasets %}
data-{{ datakey }}="{{ datavalue|e('html_attr') }}"
{% endfor %}
{% endif %}
{% if field.attributes is defined %}
{% for key,attribute in field.attributes %}
{% if attribute|of_type('array') %}
{{ attribute.name }}="{{ attribute.value|e('html_attr') }}"
{% else %}
{{ key }}="{{ attribute|e('html_attr') }}"
{% endif %}
{% endfor %}
{% endif %}
{% endblock %}
>{{ value|trim|e('html') }}</textarea>
{% block append %}{% endblock append %}
{% if inline_errors and errors %}
<div class="{{ form_errors_classes ?: 'form-errors' }}">
<p class="form-message"><i class="fa fa-exclamation-circle"></i> {{ errors|first }}</p>
</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1 @@
{{ value ? value|date('g:i A')|e }}
@@ -0,0 +1,6 @@
{% extends "forms/field.html.twig" %}
{% block input_attributes %}
type="time"
{{ parent() }}
{% endblock %}
@@ -0,0 +1,5 @@
{%- if value -%}
<span><i class="published fa fa-check-circle"></i></span>
{%- else -%}
<span><i class="unpublished fa fa-times-circle"></i></span>
{%- endif -%}
@@ -0,0 +1,52 @@
{% extends "forms/field.html.twig" %}
{% macro spanToggle(input, length) %}
{% set space = repeat('&nbsp;&nbsp;', (length - input|length) / 2) %}
{{ (space ~ input ~ space)|raw }}
{% endmacro %}
{% import _self as macro %}
{% set has_hidden = false %}
{% for key, text in field.options %}
{% if key is empty %}
{% set has_hidden = true %}
{% endif %}
{% endfor %}
{% block global_attributes %}
{{ parent() }}
data-grav-field-name="{{ (scope ~ field.name)|fieldName }}"
{% endblock %}
{% block input %}
<div class="switch-toggle switch-grav {{ field.size }} switch-{{ field.options|length }} {{ field.classes }}">
{% set maxLen = 0 %}
{% for text in field.options %}
{% set translation = text|t|trim %}
{% set maxLen = max(translation|length, maxLen) %}
{% endfor %}
{# Value falls back to highlight instead of default #}
{% set highlight = field.highlight|string %}
{% set value = (value ?? default ?? highlight)|string %}
{% for key, text in field.options %}
{% set key = key|string %}
{% set id = (field.id ?? ("toggle_" ~ field.name)) ~ key %}
{% set translation = text|t|trim %}
<input type="radio"
value="{{ key }}"
id="{{ id }}"
name="{{ (scope ~ field.name)|fieldName }}"
{% if highlight is same as(key) %}class="highlight"{% endif %}
{% if field.disabled or isDisabledToggleable %}disabled="disabled"{% endif %}
{% if key is same as(value) %}checked="checked"{% endif %}
{% if required %}required="required"{% endif %}
{% if field.tabindex %}tabindex="{{ field.tabindex }}"{% endif %}
/>
<label for="{{ id }}">{{ (macro.spanToggle(translation, maxLen)|trim)|raw }}</label>
{% endfor %}
</div>
{% endblock %}
@@ -0,0 +1,107 @@
{% extends "forms/field.html.twig" %}
{% block label %}{% endblock %}
{% block input %}
{% set config = grav.config %}
{% set formId = form.id ?: form.name %}
{# Get configuration values with fallbacks #}
{% set site_key = field.turnstile_site_key ?? config.plugins.form.turnstile.site_key %}
{% set theme = field.turnstile_theme ?? config.plugins.form.turnstile.theme ?? 'light' %}
{% set container_id = 'cf-turnstile-' ~ formId %}
{% set init_var = 'turnstile_initialized_' ~ formId %}
{% if not site_key %}
<div class="form-error">Turnstile site key is not set. Please set it in the form field or plugin configuration.</div>
{% else %}
{# Add a hidden field for the token directly in the INPUT block to ensure it's part of the field #}
<input type="hidden" name="cf-turnstile-response" value="" class="turnstile-token" />
<div class="turnstile-container"
data-form-id="{{ formId }}"
data-captcha-provider="turnstile"
data-sitekey="{{ site_key }}"
data-theme="{{ theme }}">
<div id="{{ container_id }}" class="cf-turnstile"></div>
</div>
{% do assets.addJs('https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback_' ~ formId ~ '&render=explicit', { 'loading': 'async', 'defer': '' }) %}
<script>
(function() {
// Prevent multiple initialization
if (window['{{ init_var }}']) {
console.log('Turnstile already initialized for form {{ formId }}');
return;
}
// Mark as initialized
window['{{ init_var }}'] = true;
// Use unique callback name to avoid conflicts with multiple forms
window['onloadTurnstileCallback_{{ formId }}'] = function() {
console.log('Turnstile API loaded - initializing widget for {{ formId }}');
const container = document.getElementById('{{ container_id }}');
if (!container) {
console.error('Turnstile container #{{ container_id }} not found');
return;
}
const form = document.getElementById('{{ formId }}');
if (!form) {
console.error('Cannot find form #{{ formId }}');
return;
}
// Find the token input field
const tokenField = form.querySelector('input[name="cf-turnstile-response"]');
if (!tokenField) {
console.error('Token field not found in form #{{ formId }}');
}
// Check if this container already has a widget (avoid duplicates)
if (container.querySelector('iframe')) {
console.log('Turnstile widget already initialized in this container');
return;
}
try {
turnstile.render('#{{ container_id }}', {
sitekey: '{{ site_key }}',
theme: '{{ theme }}',
callback: function(token) {
console.log('Turnstile callback fired with token', token.substring(0, 10) + '...');
// Update the token field
if (tokenField) {
tokenField.value = token;
console.log('Updated token field with value');
}
},
'expired-callback': function() {
console.warn('Turnstile token expired');
if (tokenField) {
tokenField.value = '';
}
},
'error-callback': function(error) {
console.error('Turnstile error:', error);
}
});
console.log('Turnstile render call completed');
} catch (e) {
console.error('Error initializing Turnstile:', e);
}
};
// Check if we can initialize immediately
if (document.getElementById('{{ container_id }}') && typeof turnstile !== 'undefined') {
window['onloadTurnstileCallback_{{ formId }}']();
}
})();
</script>
{% endif %}
{% endblock %}
@@ -0,0 +1,5 @@
{% extends "forms/field.html.twig" %}
{% block field %}
<input type="hidden" name="__unique_form_id__" value="{{ form.uniqueid() ?? random_string(20) }}" />
{% endblock %}
@@ -0,0 +1 @@
<a href="{{ url(value) }}" target="_blank"><i class="fa fa-link"></i> {{ value|e }}</a>
@@ -0,0 +1,9 @@
{% extends "forms/field.html.twig" %}
{% block input_attributes %}
type="url"
{% if field.size %}size="{{ field.size }}"{% endif %}
{% if field.minlength is defined or field.validate.min is defined %}minlength="{{ field.minlength | default(field.validate.min) }}"{% endif %}
{% if field.maxlength is defined or field.validate.max is defined %}maxlength="{{ field.maxlength | default(field.validate.max) }}"{% endif %}
{{ parent() }}
{% endblock %}
@@ -0,0 +1,18 @@
{% extends "forms/field.html.twig" %}
{% if field.options %}
{% set value = field.options[value] ?: value %}
{% endif %}
{% block input %}
<span class="field-value">
{% switch field.filter %}
{% case 'date' %}
{{ value|date }}
{% case 'raw' %}
{{ value|raw }}
{% default %}
{{ value }}
{% endswitch %}
</span>
{% endblock %}
@@ -0,0 +1,6 @@
{% extends "forms/field.html.twig" %}
{% block input_attributes %}
type="week"
{{ parent() }}
{% endblock %}