Add premium logo and professional theme for high-end clients

- Create custom SVG logo with professional branding
- Implement premium color scheme with blue and gold accents
- Add custom CSS with professional styling for cards, tables, buttons
- Update logo template to use new logo.svg file
- Create custom favicon for complete branding
- Redesign homepage with premium content sections
- Update resources page with membership tiers and premium pricing
- Enhance contact page with testimonials and detailed information
- Target audience: high-paying clients ($100+/hour)
- Professional yet approachable design language

💘 Generated with Crush

Assisted-by: GLM-4.7 via Crush <crush@charm.land>
This commit is contained in:
Charles N Wyble
2026-01-13 16:15:40 -05:00
parent e6c15cafb3
commit 9f7fe553dc
2596 changed files with 433475 additions and 113 deletions

View File

@@ -0,0 +1,165 @@
{
"env": {
"browser": true,
"node": true
},
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module"
},
"rules": {
"accessor-pairs": 2,
"array-bracket-spacing": 0,
"block-scoped-var": 0,
"brace-style": [2, "1tbs", { "allowSingleLine": true }],
"camelcase": 0,
"comma-dangle": [2, "never"],
"comma-spacing": [2, { "before": false, "after": true }],
"comma-style": [2, "last"],
"complexity": 0,
"computed-property-spacing": 0,
"consistent-return": 0,
"consistent-this": 0,
"constructor-super": 2,
"curly": [2, "multi-line"],
"default-case": 0,
"dot-location": [2, "property"],
"dot-notation": 0,
"eol-last": 2,
"eqeqeq": [2, "allow-null"],
"func-names": 0,
"func-style": 0,
"generator-star-spacing": [2, { "before": true, "after": true }],
"guard-for-in": 0,
"handle-callback-err": [2, "^(err|error)$" ],
"indent": [2, 4, { "SwitchCase": 1 }],
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
"linebreak-style": 0,
"lines-around-comment": 0,
"max-nested-callbacks": 0,
"new-cap": [2, { "newIsCap": true, "capIsNew": false }],
"new-parens": 2,
"newline-after-var": 0,
"no-alert": 0,
"no-array-constructor": 2,
"no-caller": 2,
"no-catch-shadow": 0,
"no-cond-assign": 2,
"no-console": 0,
"no-constant-condition": 0,
"no-continue": 0,
"no-control-regex": 2,
"no-debugger": 2,
"no-delete-var": 2,
"no-div-regex": 0,
"no-dupe-args": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-else-return": 0,
"no-empty": 0,
"no-empty-character-class": 2,
"no-eq-null": 0,
"no-eval": 2,
"no-ex-assign": 2,
"no-extend-native": 2,
"no-extra-bind": 2,
"no-extra-boolean-cast": 2,
"no-extra-parens": 0,
"no-extra-semi": 0,
"no-fallthrough": 2,
"no-floating-decimal": 2,
"no-func-assign": 2,
"no-implied-eval": 2,
"no-inline-comments": 0,
"no-inner-declarations": [2, "functions"],
"no-invalid-regexp": 2,
"no-irregular-whitespace": 2,
"no-iterator": 2,
"no-label-var": 2,
"no-labels": 2,
"no-lone-blocks": 2,
"no-lonely-if": 0,
"no-loop-func": 0,
"no-mixed-requires": 0,
"no-mixed-spaces-and-tabs": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-multiple-empty-lines": [2, { "max": 1 }],
"no-native-reassign": 2,
"no-negated-in-lhs": 2,
"no-nested-ternary": 0,
"no-new": 2,
"no-new-func": 0,
"no-new-object": 2,
"no-new-require": 2,
"no-new-wrappers": 2,
"no-obj-calls": 2,
"no-octal": 2,
"no-octal-escape": 2,
"no-param-reassign": 0,
"no-path-concat": 0,
"no-process-env": 0,
"no-process-exit": 0,
"no-proto": 0,
"no-redeclare": 2,
"no-regex-spaces": 2,
"no-restricted-modules": 0,
"no-return-assign": 2,
"no-script-url": 0,
"no-self-compare": 2,
"no-sequences": 2,
"no-shadow": 0,
"no-shadow-restricted-names": 2,
"no-spaced-func": 2,
"no-sparse-arrays": 2,
"no-sync": 0,
"no-ternary": 0,
"no-this-before-super": 2,
"no-throw-literal": 2,
"no-trailing-spaces": 2,
"no-undef": 2,
"no-undef-init": 2,
"no-undefined": 0,
"no-underscore-dangle": 0,
"no-unexpected-multiline": 2,
"no-unneeded-ternary": 2,
"no-unreachable": 2,
"no-unused-expressions": 0,
"no-unused-vars": [2, { "vars": "all", "args": "none" }],
"no-use-before-define": 0,
"no-var": 0,
"no-void": 0,
"no-warning-comments": 0,
"no-with": 2,
"object-curly-spacing": 0,
"object-shorthand": 0,
"one-var": [2, { "initialized": "never" }],
"operator-assignment": 0,
"operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before" } }],
"padded-blocks": 0,
"prefer-const": 0,
"quote-props": 0,
"quotes": [2, "single", "avoid-escape"],
"radix": 2,
"semi": [2, "always"],
"semi-spacing": 0,
"sort-vars": 0,
"keyword-spacing": [2, {"after": true, "overrides": {"throw": { "after": true}, "return": { "before": true }}}],
"space-before-blocks": [2, "always"],
"space-before-function-paren": [2, "never"],
"space-in-parens": [2, "never"],
"space-infix-ops": 2,
"space-unary-ops": [2, { "words": true, "nonwords": false }],
"spaced-comment": [2, "always", { "markers": ["global", "globals", "eslint", "eslint-disable", "*package", "!"] }],
"strict": 0,
"use-isnan": 2,
"valid-jsdoc": 0,
"valid-typeof": 2,
"vars-on-top": 0,
"wrap-iife": [2, "any"],
"wrap-regex": 0,
"yoda": [2, "never"]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Grav
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,41 @@
# Grav Form Plugin
The **form plugin** for [Grav](https://github.com/getgrav/grav) adds the ability to create and use forms. This is currently used extensively by the **admin** and **login** plugins.
# Installation
The form plugin is easy to install with GPM.
```
$ bin/gpm install form
```
# Configuration
Simply copy the `user/plugins/form/form.yaml` into `user/config/plugins/form.yaml` and make your modifications.
```
enabled: true
```
# How to use the Form Plugin
The Learn site has two pages describing how to use the Form Plugin:
- [Forms](https://learn.getgrav.org/forms)
- [Add a contact form](https://learn.getgrav.org/forms/forms/example-form)
# Using email
Note: when using email functionality in your forms, make sure you have configured the Email plugin correctly. In particular, make sure you configured the "Email from" and "Email to" email addresses in the Email plugin with your email address.
# NOTES:
As of version **Form 6.0.0** forms are no longer initialized before caching, but when the form is requested. This has been done to make dynamic forms to work better with caching. There may be some backward compatibility issues for logic that modifies pages with forms as the modification doesn't happen without accessing the form first.
As of version **Form 5.0.0** Grav 1.7+ is required.
As of version **Form 4.0.6**, form labels are now being output with the `|raw` filter. If you wish to show HTML in your form label, ie `Root Folder <root>`, then you need to escape that in your form definition:
```yaml
label: Root Folder &lt;root&gt;
```

View File

@@ -0,0 +1,200 @@
import $ from 'jquery';
import Sortable from 'sortablejs';
let body = $('body');
class Template {
constructor(container) {
this.container = $(container);
if (this.getName() === undefined) {
this.container = this.container.closest('[data-grav-array-name]');
}
}
getName() {
return this.container.data('grav-array-name') || '';
}
getKeyPlaceholder() {
return this.container.data('grav-array-keyname') || 'Key';
}
getValuePlaceholder() {
return this.container.data('grav-array-valuename') || 'Value';
}
isValueOnly() {
return this.container.find('[data-grav-array-mode="value_only"]:first').length || false;
}
isTextArea() {
return this.container.data('grav-array-textarea') || false;
}
shouldBeDisabled() {
// check for toggleables, if field is toggleable and it's not enabled, render disabled
let toggle = this.container.closest('.form-field').find('[data-grav-field="toggleable"] input[type="checkbox"]');
return toggle.length && toggle.is(':not(:checked)');
}
getNewRow() {
let tpl = '';
const value = this.isTextArea()
? `<textarea ${this.shouldBeDisabled() ? 'disabled="disabled"' : ''} data-grav-array-type="value" name="" placeholder="${this.getValuePlaceholder()}"></textarea>`
: `<input ${this.shouldBeDisabled() ? 'disabled="disabled"' : ''} data-grav-array-type="value" type="text" name="" value="" placeholder="${this.getValuePlaceholder()}" />`;
if (this.isValueOnly()) {
tpl += `
<div class="form-row array-field-value_only" data-grav-array-type="row">
<span data-grav-array-action="sort" class="fa fa-bars"></span>
${value}
`;
} else {
tpl += `
<div class="form-row" data-grav-array-type="row">
<span data-grav-array-action="sort" class="fa fa-bars"></span>
<input ${this.shouldBeDisabled() ? 'disabled="disabled"' : ''} data-grav-array-type="key" type="text" value="" placeholder="${this.getKeyPlaceholder()}" />
${value}
`;
}
tpl += `
<span data-grav-array-action="rem" class="fa fa-minus"></span>
<span data-grav-array-action="add" class="fa fa-plus"></span>
</div>`;
return tpl;
}
}
export default class ArrayField {
constructor() {
body.on('input', '[data-grav-array-type="key"], [data-grav-array-type="value"]', (event) => this.actionInput(event));
body.on('click touch', '[data-grav-array-action]:not([data-grav-array-action="sort"])', (event) => this.actionEvent(event));
this.arrays = $();
$('[data-grav-field="array"]').each((index, list) => this.addArray(list));
$('body').on('mutation._grav', this._onAddedNodes.bind(this));
}
addArray(list) {
list = $(list);
list.find('[data-grav-array-type="container"]').each((index, container) => {
container = $(container);
if (container.data('array-sort') || container[0].hasAttribute('data-array-nosort')) { return; }
container.data('array-sort', new Sortable(container.get(0), {
handle: '.fa-bars',
animation: 150
}));
});
}
actionInput(event) {
let element = $(event.target);
let type = element.data('grav-array-type');
this._setTemplate(element);
let template = element.data('array-template');
let keyElement = type === 'key' ? element : element.siblings('[data-grav-array-type="key"]:first');
let valueElement = type === 'value' ? element : element.siblings('[data-grav-array-type="value"]:first');
let escaped_name = !template.isValueOnly() ? keyElement.val() : this.getIndexFor(element);
escaped_name = escaped_name.toString().replace(/\[/g, '%5B').replace(/]/g, '%5D');
let name = `${template.getName()}[${escaped_name}]`;
if (!template.isValueOnly() && (!keyElement.val() && !valueElement.val())) {
valueElement.attr('name', '');
} else {
// valueElement.attr('name', !valueElement.val() ? template.getName() : name);
valueElement.attr('name', name);
}
this.refreshNames(template);
}
actionEvent(event) {
event && event.preventDefault();
let element = $(event.target);
let action = element.data('grav-array-action');
let container = element.parents('[data-grav-array-type="container"]');
this._setTemplate(element);
this[`${action}Action`](element);
let siblings = container.find('> div');
container[siblings.length > 1 ? 'removeClass' : 'addClass']('one-child');
}
addAction(element) {
let template = element.data('array-template');
let row = element.closest('[data-grav-array-type="row"]');
row.after(template.getNewRow());
}
remAction(element) {
let template = element.data('array-template');
let row = element.closest('[data-grav-array-type="row"]');
let isLast = !row.siblings().length;
if (isLast) {
let newRow = $(template.getNewRow());
row.after(newRow);
newRow.find('[data-grav-array-type="value"]:last').attr('name', template.getName());
}
row.remove();
this.refreshNames(template);
}
refreshNames(template) {
if (!template.isValueOnly()) { return; }
let row = template.container.find('> div > [data-grav-array-type="row"]');
let inputs = row.find('[name]:not([name=""])');
inputs.each((index, input) => {
input = $(input);
let name = input.attr('name');
name = name.replace(/\[\d+\]$/, `[${index}]`);
input.attr('name', name);
});
if (!inputs.length) {
row.find('[data-grav-array-type="value"]').attr('name', template.getName());
}
}
getIndexFor(element) {
let template = element.data('array-template');
let row = element.closest('[data-grav-array-type="row"]');
return template.container.find(`${template.isValueOnly() ? '> div ' : ''} > [data-grav-array-type="row"]`).index(row);
}
_setTemplate(element) {
if (!element.data('array-template')) {
element.data('array-template', new Template(element.closest('[data-grav-array-name]')));
}
}
_onAddedNodes(event, target/* , record, instance */) {
let arrays = $(target).find('[data-grav-field="array"]');
if (!arrays.length) { return; }
arrays.each((index, list) => {
list = $(list);
if (!~this.arrays.index(list)) {
this.addArray(list);
}
});
}
}
export let Instance = new ArrayField();

View File

@@ -0,0 +1,368 @@
import $ from 'jquery';
import Dropzone from 'dropzone';
// import EXIF from 'exif-js';
import {config, translations} from 'grav-form';
// translations
const Dictionary = {
dictCancelUpload: translations.PLUGIN_FORM.DROPZONE_CANCEL_UPLOAD,
dictCancelUploadConfirmation: translations.PLUGIN_FORM.DROPZONE_CANCEL_UPLOAD_CONFIRMATION,
dictDefaultMessage: translations.PLUGIN_FORM.DROPZONE_DEFAULT_MESSAGE,
dictFallbackMessage: translations.PLUGIN_FORM.DROPZONE_FALLBACK_MESSAGE,
dictFallbackText: translations.PLUGIN_FORM.DROPZONE_FALLBACK_TEXT,
dictFileTooBig: translations.PLUGIN_FORM.DROPZONE_FILE_TOO_BIG,
dictInvalidFileType: translations.PLUGIN_FORM.DROPZONE_INVALID_FILE_TYPE,
dictMaxFilesExceeded: translations.PLUGIN_FORM.DROPZONE_MAX_FILES_EXCEEDED,
dictRemoveFile: translations.PLUGIN_FORM.DROPZONE_REMOVE_FILE,
dictRemoveFileConfirmation: translations.PLUGIN_FORM.DROPZONE_REMOVE_FILE_CONFIRMATION,
dictResponseError: translations.PLUGIN_FORM.DROPZONE_RESPONSE_ERROR
};
Dropzone.autoDiscover = false;
const DropzoneMediaConfig = {
createImageThumbnails: {thumbnailWidth: 150},
addRemoveLinks: false,
dictDefaultMessage: Dictionary.dictDefaultMessage,
dictRemoveFileConfirmation: Dictionary.dictRemoveFileConfirmation,
previewTemplate: ''
};
// window.EXIF = EXIF;
export default class FilesField {
constructor({container = '.dropzone.files-upload', options = {}} = {}) {
this.container = $(container);
if (!this.container.length) {
return;
}
this.urls = {};
DropzoneMediaConfig.previewTemplate = $('#dropzone-template').html();
this.options = Object.assign({}, Dictionary, DropzoneMediaConfig, {
klass: this,
url: this.container.data('file-url-add') || config.current_url,
acceptedFiles: this.container.data('media-types'),
init: this.initDropzone
}, this.container.data('dropzone-options'), options);
this.dropzone = new Dropzone(container, this.options);
this.dropzone.on('complete', this.onDropzoneComplete.bind(this));
this.dropzone.on('success', this.onDropzoneSuccess.bind(this));
this.dropzone.on('removedfile', this.onDropzoneRemovedFile.bind(this));
this.dropzone.on('sending', this.onDropzoneSending.bind(this));
this.dropzone.on('error', this.onDropzoneError.bind(this));
}
initDropzone() {
let files = this.options.klass.container.find('[data-file]');
let dropzone = this;
if (!files.length) {
return;
}
files.each((index, file) => {
file = $(file);
let data = file.data('file');
let mock = {
name: data.name,
size: data.size,
type: data.type,
status: Dropzone.ADDED,
accepted: true,
url: this.options.url,
removeUrl: data.remove,
data
};
dropzone.files.push(mock);
dropzone.options.addedfile.call(dropzone, mock);
if (mock.type.match(/^image\//)) dropzone.options.thumbnail.call(dropzone, mock, data.path);
file.remove();
});
}
getURI() {
return this.container.data('mediaUri') || '';
}
onDropzoneSending(file, xhr, formData) {
const form = this.container.closest('form');
const unique_id = form.find('[name="__unique_form_id__"]');
formData.append('__form-name__', form.find('[name="__form-name__"]').val());
if (unique_id.length) {
formData.append('__unique_form_id__', unique_id.val());
}
formData.append('__form-file-uploader__', 1);
formData.append('name', this.options.dotNotation);
formData.append('form-nonce', config.form_nonce);
formData.append('task', 'filesupload');
formData.append('uri', this.getURI());
}
onDropzoneSuccess(file, response, xhr) {
if (this.options.reloadPage) {
global.location.reload();
}
if (response && response.status === 'error') {
return this.handleError({
file,
data: response,
mode: 'removeFile',
msg: `<p>${translations.PLUGIN_FORM.FILE_ERROR_UPLOAD} <strong>${file.name}</strong></p>
<pre>${response.message}</pre>`
});
}
// store params for removing file from session before it gets saved
if (response && response.session) {
file.sessionParams = response.session;
file.removeUrl = this.options.url;
// Touch field value to force a mutation detection
const input = this.container.find('[name][type="hidden"]');
const value = input.val();
input.val(value + ' ');
}
return true;
}
onDropzoneComplete(file) {
if (!file.accepted && !file.rejected) {
let data = {
status: 'error',
message: `${translations.PLUGIN_FORM.FILE_UNSUPPORTED}: ${file.name.match(/\..+/).join('')}`
};
return this.handleError({
file,
data,
mode: 'removeFile',
msg: `<p>${translations.PLUGIN_FORM.FILE_ERROR_ADD} <strong>${file.name}</strong></p>
<pre>${data.message}</pre>`
});
}
if (this.options.reloadPage) {
global.location.reload();
}
}
onDropzoneRemovedFile(file, ...extra) {
if (!file.accepted || file.rejected) {
return;
}
const form = this.container.closest('form');
const unique_id = form.find('[name="__unique_form_id__"]');
let url = file.removeUrl || this.urls.delete || `${location.href}.json`;
let path = (url || '').match(/path:(.*)\//);
let data = new FormData();
data.append('filename', file.name);
data.append('__form-name__', form.find('[name="__form-name__"]').val());
data.append('name', this.options.dotNotation);
data.append('form-nonce', config.form_nonce);
data.append('uri', this.getURI());
if (file.sessionParams) {
data.append('__form-file-remover__', '1');
data.append('session', file.sessionParams);
}
if (unique_id.length) {
data.append('__unique_form_id__', unique_id.val());
}
$.ajax({
url,
data,
method: 'POST',
contentType: false,
processData: false,
success: () => {
if (!path) {
return;
}
path = global.atob(path[1]);
let input = this.container.find('[name][type="hidden"]');
let data = JSON.parse(input.val() || '{}');
delete data[path];
input.val(JSON.stringify(data));
}
});
}
onDropzoneError(file, response, xhr) {
let message = xhr && response.error ? response.error.message : response;
$(file.previewElement).find('[data-dz-errormessage]').html(message);
return this.handleError({
file,
data: {status: 'error'},
msg: `<pre>${message}</pre>`
});
}
handleError(options) {
const { file, data, msg } = options;
const status = data && data.status;
if (status !== 'error' && status !== 'unauthorized') {
return false;
}
const message = data && data.message ? data.message : (msg || translations.PLUGIN_FORM.FILEPOND_ERROR_FILESIZE);
if (file && this.dropzone) {
file.accepted = false;
file.status = Dropzone.ERROR;
file.rejected = true;
const preview = $(file.previewElement);
if (preview.length) {
preview.addClass('dz-error');
preview.find('[data-dz-errormessage]').html(message);
}
// Remove the errored file so the user can try again.
if (~this.dropzone.files.indexOf(file)) {
setTimeout(() => {
this.dropzone.removeFile.call(this.dropzone, file, { silent: true });
this.dropzone._updateMaxFilesReachedClass();
}, 100);
}
}
const field = this.container.closest('.form-field');
if (field.length) {
let errorBox = field.find('.form-errors');
if (!errorBox.length) {
errorBox = $('<div class="form-errors"></div>').appendTo(field);
}
errorBox.html(`<p class="form-message"><i class="fa fa-exclamation-circle"></i> ${message}</p>`);
} else if (typeof global.alert === 'function') {
// Fall back to alert if no inline container is present.
global.alert(message);
}
return true;
}
}
/*
export function UriToMarkdown(uri) {
uri = uri.replace(/@3x|@2x|@1x/, '');
uri = uri.replace(/\(/g, '%28');
uri = uri.replace(/\)/g, '%29');
return uri.match(/\.(jpe?g|png|gif|svg)$/i) ? `![](${uri})` : `[${decodeURI(uri)}](${uri})`;
}
*/
let instances = [];
let cache = $();
const onAddedNodes = (event, target/* , record, instance */) => {
let files = $(target).find('.dropzone.files-upload');
if (!files.length) {
return;
}
files.each((index, file) => {
file = $(file);
if (!~cache.index(file)) {
addNode(file);
}
});
};
const addNode = (container) => {
container = $(container);
let input = container.find('input[type="file"]');
let settings = container.data('grav-file-settings') || {};
if (settings.accept && ~settings.accept.indexOf('*')) {
settings.accept = [''];
}
let options = {
url: container.data('file-url-add') || (container.closest('form').attr('action') || config.current_url) + '.json',
paramName: settings.paramName || 'file',
dotNotation: settings.name || 'file',
acceptedFiles: settings.accept ? settings.accept.join(',') : input.attr('accept') || container.data('media-types'),
maxFilesize: settings.filesize || 256,
maxFiles: settings.limit || null,
resizeWidth: settings.resizeWidth || null,
resizeHeight: settings.resizeHeight || null,
resizeQuality: settings.resizeQuality || null,
accept: function(file, done) {
const resolution = settings.resolution;
let error = '';
if (!resolution) return done();
if ((this.options.maxFiles != null) && (this.getAcceptedFiles().length >= this.options.maxFiles)) {
done(this.options.dictMaxFilesExceeded.replace('{{maxFiles}}', this.options.maxFiles));
return this.emit('maxfilesexceeded', file);
}
const reader = new FileReader();
if (resolution.min || (!(settings.resizeWidth || settings.resizeHeight) && resolution.max)) {
reader.onload = function(event) {
const image = new Image();
image.src = event.target.result;
image.onload = function() {
if (resolution.min) {
Object.keys(resolution.min).forEach((attr) => {
if (this[attr] < resolution.min[attr]) {
error += translations.PLUGIN_FORM.RESOLUTION_MIN.replace(/{{attr}}/g, attr).replace(/{{min}}/g, resolution.min[attr]);
}
});
}
if (!(settings.resizeWidth || settings.resizeHeight)) {
if (resolution.max) {
Object.keys(resolution.max).forEach((attr) => {
if (this[attr] > resolution.max[attr]) {
error += translations.PLUGIN_FORM.RESOLUTION_MAX.replace(/{{attr}}/g, attr).replace(/{{max}}/g, resolution.max[attr]);
}
});
}
}
done(error);
};
};
reader.readAsDataURL(file);
} else {
return done(error);
}
}
};
cache = cache.add(container);
container = container[0];
instances.push(new FilesField({container, options}));
};
export let Instances = (() => {
$(document).ready(() => {
$('.dropzone.files-upload').each((i, container) => addNode(container));
$('body').on('mutation._grav', onAddedNodes);
});
return instances;
})();
// Expose addNode function to global scope for XHR reinitialization and pipeline compatibility
if (typeof window.GravForm === 'undefined') {
window.GravForm = {};
}
window.GravForm.FilesField = {
addNode,
instances: Instances
};

View File

@@ -0,0 +1,94 @@
import $ from 'jquery';
const attachToggleables = (form) => {
form = $(form);
let query = '[data-grav-field="toggleable"] input[type="checkbox"]';
form.on('change', query, (event) => {
let toggle = $(event.target);
let enabled = toggle.is(':checked');
let parent = toggle.closest('.form-field');
let label = parent.find('label.toggleable');
let fields = parent.find('.form-data');
let inputs = fields.find('input, select, textarea, button');
label.add(fields).css('opacity', enabled ? '' : 0.7);
inputs.map((index, input) => {
let isSelectize = input.selectize;
input = $(input);
if (isSelectize) {
isSelectize[enabled ? 'enable' : 'disable']();
} else {
input.prop('disabled', !enabled);
}
});
});
form.find(query).trigger('change');
};
const attachDisabledFields = (form) => {
form = $(form);
let prefix = '.form-field-toggleable .form-data';
let query = [];
['input', 'select', 'label[for]', 'textarea', '.selectize-control'].forEach((item) => {
query.push(`${prefix} ${item}`);
});
form.on('mousedown', query.join(', '), (event) => {
let input = $(event.target);
let isFor = input.prop('for');
let isSelectize = (input.hasClass('selectize-control') || input.parents('.selectize-control')).length;
if (isFor) { input = $(`[id="${isFor}"]`); }
if (isSelectize) { input = input.closest('.selectize-control').siblings('select[name]'); }
if (!input.prop('disabled')) { return true; }
let toggle = input.closest('.form-field').find('[data-grav-field="toggleable"] input[type="checkbox"]');
toggle.trigger('click');
});
};
/*
const submitUncheckedFields = (forms) => {
forms = $(forms);
let submitted = false;
forms.each((index, form) => {
form = $(form);
form.on('submit', () => {
// workaround for MS Edge, submitting multiple forms at the same time
if (submitted) { return false; }
let formId = form.attr('id');
let unchecked = form.find('input[type="checkbox"]:not(:checked):not(:disabled)');
let submit = form.find('[type="submit"]').add(`[form="${formId}"][type="submit"]`);
if (!unchecked.length) { return true; }
submit.addClass('pointer-events-disabled');
unchecked.each((index, element) => {
element = $(element);
let name = element.prop('name');
let fake = $(`<input type="hidden" name="${name}" value="0" />`);
form.append(fake);
});
submitted = true;
return true;
});
});
};
*/
$(document).ready(() => {
const forms = $('form').filter((form) => $(form).find('[name="__form-name__"]'));
if (!forms.length) { return; }
forms.each((index, form) => {
attachToggleables(form);
attachDisabledFields(form);
// submitUncheckedFields(form);
});
});

View File

@@ -0,0 +1,7 @@
import './form';
import FileInstances from './file';
import ArrayInstances from './array';
import PageMedia, { Instance as PageMediaInstances } from './media';
import './tabs';
export default { FileInstances, ArrayInstances, Media: { PageMedia, PageMediaInstances } };

View File

@@ -0,0 +1,174 @@
import $ from 'jquery';
import FilesField from './file';
import { config, translations } from 'grav-form';
import Sortable from 'sortablejs';
const template = `
<div class="dz-preview dz-file-preview">
<div class="dz-details">
<div class="dz-filename"><span data-dz-name></span></div>
<div class="dz-size" data-dz-size></div>
<img data-dz-thumbnail />
</div>
<div class="dz-progress"><span class="dz-upload" data-dz-uploadprogress></span></div>
<div class="dz-success-mark"><span>✔</span></div>
<div class="dz-error-mark"><span>✘</span></div>
<div class="dz-error-message"><span data-dz-errormessage></span></div>
<a class="dz-remove" title="${translations.PLUGIN_FORM.DELETE}" href="javascript:undefined;" data-dz-remove>${translations.PLUGIN_FORM.DELETE}</a>
</div>`.trim();
export default class PageMedia extends FilesField {
constructor({ container = '#grav-dropzone', options = {} } = {}) {
const previewTemplate = $('#dropzone-media-template').html() || template;
options = Object.assign(options, { previewTemplate });
super({ container, options });
if (!this.container.length) { return; }
this.urls = {
fetch: `${this.container.data('media-url')}/task${config.param_sep}listmedia`,
add: `${this.container.data('media-url')}/task${config.param_sep}addmedia`,
delete: `${this.container.data('media-url')}/task${config.param_sep}delmedia`
};
this.dropzone.options.url = this.urls.add;
if (typeof this.options.fetchMedia === 'undefined' || this.options.fetchMedia) {
this.fetchMedia();
}
const field = $(`[name="${this.container.data('dropzone-field')}"]`);
if (field.length) {
this.sortable = new Sortable(this.container.get(0), {
animation: 150,
// forceFallback: true,
setData: (dataTransfer, target) => {
target = $(target);
this.dropzone.disable();
target.addClass('hide-backface');
dataTransfer.effectAllowed = 'copy';
},
onSort: () => {
let names = [];
this.container.find('[data-dz-name]').each((index, file) => {
file = $(file);
const name = file.text().trim();
names.push(name);
});
field.val(names.join(','));
}
});
}
}
onDropzoneRemovedFile(file, ...extra) {
if (!file.accepted || file.rejected) { return; }
const form = this.container.closest('form');
const unique_id = form.find('[name="__unique_form_id__"]');
let url = file.removeUrl || this.urls.delete || `${location.href}.json`;
let path = (url || '').match(/path:(.*)\//);
let data = new FormData();
data.append('filename', file.name);
data.append('__form-name__', form.find('[name="__form-name__"]').val());
if (unique_id.length) {
data.append('__unique_form_id__', unique_id.val());
}
data.append('name', this.options.dotNotation);
data.append('form-nonce', config.form_nonce);
if (file.sessionParams) {
data.append('__form-file-remover__', '1');
data.append('session', file.sessionParams);
}
$.ajax({
url,
data,
method: 'POST',
contentType: false,
processData: false,
success: () => {
if (!path) { return; }
path = global.atob(path[1]);
let input = this.container.find('[name][type="hidden"]');
let data = JSON.parse(input.val() || '{}');
delete data[path];
input.val(JSON.stringify(data));
}
});
}
fetchMedia() {
const order = this.container.closest('.form-field').find('[name="data[header][media_order]"]').val();
const data = { order };
let url = this.urls.fetch;
$.ajax({
url,
method: 'POST',
data,
success: (response) => {
if (typeof response === 'string' || response instanceof String) {
return false;
}
response = response.results;
Object.keys(response).forEach((name) => {
let data = response[name];
let mock = { name, size: data.size, accepted: true, extras: data };
this.dropzone.files.push(mock);
this.dropzone.options.addedfile.call(this.dropzone, mock);
this.dropzone.options.thumbnail.call(this.dropzone, mock, data.url);
});
this.container.find('.dz-preview').prop('draggable', 'true');
}
});
/*
request(url, { method: 'post', body }, (response) => {
let results = response.results;
Object.keys(results).forEach((name) => {
let data = results[name];
let mock = { name, size: data.size, accepted: true, extras: data };
this.dropzone.files.push(mock);
this.dropzone.options.addedfile.call(this.dropzone, mock);
this.dropzone.options.thumbnail.call(this.dropzone, mock, data.url);
});
this.container.find('.dz-preview').prop('draggable', 'true');
});*/
}
onDropzoneSending(file, xhr, formData) {
/*
// Cannot call super because Safari and IE API don't implement `delete`
super.onDropzoneSending(file, xhr, formData);
formData.delete('task');
*/
formData.append('name', this.options.dotNotation);
formData.append('admin-nonce', config.admin_nonce);
}
onDropzoneComplete(file) {
super.onDropzoneComplete(file);
this.sortable.options.onSort();
// accepted
$('.dz-preview').prop('draggable', 'true');
}
// onDropzoneRemovedFile(file, ...extra) {
// super.onDropzoneRemovedFile(file, ...extra);
// this.sortable.options.onSort();
// }
}
export let Instance = new PageMedia();

View File

@@ -0,0 +1,14 @@
import $ from 'jquery';
$('body').on('touchstart click', '[data-tabid]', (event) => {
event && event.stopPropagation();
let target = $(event.currentTarget);
const panel = $(`[id="${target.data('tabid')}"]`);
target.siblings('[data-tabid]').removeClass('active');
target.addClass('active');
panel.siblings('[id]').removeClass('active');
panel.addClass('active');
});

View File

@@ -0,0 +1,4 @@
import Instances from './fields';
import './utils/keep-alive';
export { Instances };

View File

@@ -0,0 +1,17 @@
import $ from 'jquery';
import {config} from 'grav-form';
const MAX_SAFE_DELAY = 2147483647;
$(document).ready(() => {
const keepAlive = $('[data-grav-keepalive]');
if (keepAlive.length) {
const timeout = config.session_timeout / 1.5 * 1000;
setInterval(() => {
$.ajax({
url: `${config.base_url_relative}/task${config.param_sep}keep-alive`
});
}, Math.min(timeout, MAX_SAFE_DELAY));
}
});

View File

@@ -0,0 +1,128 @@
(function() {
'use strict';
// Function to refresh a captcha image
const refreshCaptchaImage = function(container) {
const img = container.querySelector('img');
if (!img) {
console.warn('Cannot find captcha image in container');
return;
}
// Get the base URL and field ID
const baseUrl = img.dataset.baseUrl || img.src.split('?')[0];
const fieldId = img.dataset.fieldId || container.dataset.fieldId;
// Force reload by adding/updating timestamp and field ID
const timestamp = new Date().getTime();
let newUrl = baseUrl + '?t=' + timestamp;
if (fieldId) {
newUrl += '&field=' + fieldId;
}
img.src = newUrl;
// Also clear the input field if we can find it
const formField = container.closest('.form-field');
if (formField) {
const input = formField.querySelector('input[type="text"]');
if (input) {
input.value = '';
// Try to focus the input
try { input.focus(); } catch(e) {}
}
}
};
// Function to set up click handlers for refresh buttons
const setupRefreshButtons = function() {
// Find all captcha containers
const containers = document.querySelectorAll('[data-captcha-provider="basic-captcha"]');
containers.forEach(function(container) {
// Find the refresh button within this container
const button = container.querySelector('button');
if (!button) {
return;
}
// Remove any existing listeners (just in case)
button.removeEventListener('click', handleRefreshClick);
// Add the click handler
button.addEventListener('click', handleRefreshClick);
});
};
// Click handler function
const handleRefreshClick = function(event) {
// Prevent default behavior and stop propagation
event.preventDefault();
event.stopPropagation();
// Find the container
const container = this.closest('[data-captcha-provider="basic-captcha"]');
if (!container) {
return false;
}
// Refresh the image
refreshCaptchaImage(container);
return false;
};
// Set up a mutation observer to handle dynamically added captchas
const setupMutationObserver = function() {
// Check if MutationObserver is available
if (typeof MutationObserver === 'undefined') return;
// Create a mutation observer to watch for new captcha elements
const observer = new MutationObserver(function(mutations) {
let needsSetup = false;
mutations.forEach(function(mutation) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
// Check if any of the added nodes contain our captcha containers
for (let i = 0; i < mutation.addedNodes.length; i++) {
const node = mutation.addedNodes[i];
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if this element has or contains captcha containers
if (node.querySelector && (
node.matches('[data-captcha-provider="basic-captcha"]') ||
node.querySelector('[data-captcha-provider="basic-captcha"]')
)) {
needsSetup = true;
break;
}
}
}
}
});
if (needsSetup) {
setupRefreshButtons();
}
});
// Start observing the document
observer.observe(document.body, {
childList: true,
subtree: true
});
};
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', function() {
setupRefreshButtons();
setupMutationObserver();
// Also connect to XHR system if available (for best of both worlds)
if (window.GravFormXHR && window.GravFormXHR.captcha) {
window.GravFormXHR.captcha.register('basic-captcha', {
reset: function(container, form) {
refreshCaptchaImage(container);
}
});
}
});
})();

View File

@@ -0,0 +1,166 @@
(function() {
'use strict';
// Register the handler with the form system when it's ready
const registerRecaptchaHandler = function() {
if (window.GravFormXHR && window.GravFormXHR.captcha) {
window.GravFormXHR.captcha.register('recaptcha', {
reset: function(container, form) {
if (!form || !form.id) {
console.warn('Cannot reset reCAPTCHA: form is invalid or missing ID');
return;
}
const formId = form.id;
console.log(`Attempting to reset reCAPTCHA for form: ${formId}`);
// First try the expected ID pattern from the Twig template
const recaptchaId = `g-recaptcha-${formId}`;
// We need to look more flexibly for the container
let widgetContainer = document.getElementById(recaptchaId);
// If not found by ID, look for the div inside the captcha provider container
if (!widgetContainer) {
// Try to find it inside the captcha provider container
widgetContainer = container.querySelector('.g-recaptcha');
if (!widgetContainer) {
// If that fails, look more broadly in the form
widgetContainer = form.querySelector('.g-recaptcha');
if (!widgetContainer) {
// Last resort - create a new container if needed
console.warn(`reCAPTCHA container #${recaptchaId} not found. Creating a new one.`);
widgetContainer = document.createElement('div');
widgetContainer.id = recaptchaId;
widgetContainer.className = 'g-recaptcha';
container.appendChild(widgetContainer);
}
}
}
console.log(`Found reCAPTCHA container for form: ${formId}`);
// Get configuration from data attributes
const parentContainer = container.closest('[data-captcha-provider="recaptcha"]');
if (!parentContainer) {
console.warn('Cannot find reCAPTCHA parent container with data-captcha-provider attribute.');
return;
}
const sitekey = parentContainer.dataset.sitekey;
const version = parentContainer.dataset.version || '2-checkbox';
const isV3 = version.startsWith('3');
const isInvisible = version === '2-invisible';
if (!sitekey) {
console.warn('Cannot reinitialize reCAPTCHA - missing sitekey attribute');
return;
}
console.log(`Re-rendering reCAPTCHA widget for form: ${formId}, version: ${version}`);
// Handle V3 reCAPTCHA differently
if (isV3) {
try {
// For v3, we don't need to reset anything visible, just make sure we have the API
if (typeof grecaptcha !== 'undefined' && typeof grecaptcha.execute === 'function') {
// Create a new execution context for the form
const actionName = `form_${formId}`;
const tokenInput = form.querySelector('input[name="token"]') ||
form.querySelector('input[name="data[token]"]');
const actionInput = form.querySelector('input[name="action"]') ||
form.querySelector('input[name="data[action]"]');
if (tokenInput && actionInput) {
// Clear previous token
tokenInput.value = '';
// Set the action name
actionInput.value = actionName;
console.log(`reCAPTCHA v3 ready for execution on form: ${formId}`);
} else {
console.warn(`Cannot find token or action inputs for reCAPTCHA v3 in form: ${formId}`);
}
} else {
console.warn('reCAPTCHA v3 API not properly loaded.');
}
} catch (e) {
console.error(`Error setting up reCAPTCHA v3: ${e.message}`);
}
return;
}
// For v2, handle visible widget reset
// Clear the container to ensure fresh rendering
widgetContainer.innerHTML = '';
// Check if reCAPTCHA API is available
if (typeof grecaptcha !== 'undefined' && typeof grecaptcha.render === 'function') {
try {
// Render with a slight delay to ensure DOM is settled
setTimeout(() => {
grecaptcha.render(widgetContainer.id || widgetContainer, {
'sitekey': sitekey,
'theme': parentContainer.dataset.theme || 'light',
'size': isInvisible ? 'invisible' : 'normal',
'callback': function(token) {
console.log(`reCAPTCHA verification completed for form: ${formId}`);
// If it's invisible reCAPTCHA, submit the form automatically
if (isInvisible && window.GravFormXHR && typeof window.GravFormXHR.submit === 'function') {
window.GravFormXHR.submit(form);
}
}
});
console.log(`Successfully rendered reCAPTCHA for form: ${formId}`);
}, 100);
} catch (e) {
console.error(`Error rendering reCAPTCHA widget: ${e.message}`);
widgetContainer.innerHTML = '<p style="color:red;">Error initializing reCAPTCHA.</p>';
}
} else {
console.warn('reCAPTCHA API not available. Attempting to reload...');
// Remove existing script if any
const existingScript = document.querySelector('script[src*="google.com/recaptcha/api.js"]');
if (existingScript) {
existingScript.parentNode.removeChild(existingScript);
}
// Create new script element
const script = document.createElement('script');
script.src = `https://www.google.com/recaptcha/api.js${isV3 ? '?render=' + sitekey : ''}`;
script.async = true;
script.defer = true;
script.onload = function() {
console.log('reCAPTCHA API loaded, retrying widget render...');
setTimeout(() => {
const retryContainer = document.querySelector(`[data-captcha-provider="recaptcha"]`);
if (retryContainer && form) {
window.GravFormXHR.captcha.getProvider('recaptcha').reset(retryContainer, form);
}
}, 200);
};
document.head.appendChild(script);
}
}
});
console.log('reCAPTCHA XHR handler registered successfully');
} else {
console.error('GravFormXHR.captcha not found. Make sure the Form plugin is loaded correctly.');
}
};
// Try to register the handler immediately if GravFormXHR is already available
if (window.GravFormXHR && window.GravFormXHR.captcha) {
registerRecaptchaHandler();
} else {
// Otherwise, wait for the DOM to be fully loaded
document.addEventListener('DOMContentLoaded', function() {
// Give a small delay to ensure GravFormXHR is initialized
setTimeout(registerRecaptchaHandler, 100);
});
}
})();

View File

@@ -0,0 +1,121 @@
(function() {
'use strict';
// Register the handler with the form system when it's ready
const registerTurnstileHandler = function() {
if (window.GravFormXHR && window.GravFormXHR.captcha) {
window.GravFormXHR.captcha.register('turnstile', {
reset: function(container, form) {
const formId = form.id;
const containerId = `cf-turnstile-${formId}`;
const widgetContainer = document.getElementById(containerId);
if (!widgetContainer) {
console.warn(`Turnstile container #${containerId} not found.`);
return;
}
// Get configuration from data attributes
const parentContainer = container.closest('[data-captcha-provider="turnstile"]');
const sitekey = parentContainer ? parentContainer.dataset.sitekey : null;
if (!sitekey) {
console.warn('Cannot reinitialize Turnstile - missing sitekey attribute');
return;
}
// Clear the container to ensure fresh rendering
widgetContainer.innerHTML = '';
console.log(`Re-rendering Turnstile widget for form: ${formId}`);
// Check if Turnstile API is available
if (typeof window.turnstile !== 'undefined') {
try {
// Reset any existing widgets
try {
window.turnstile.reset(containerId);
} catch (e) {
// Ignore reset errors, we'll re-render anyway
}
// Render with a slight delay to ensure DOM is settled
setTimeout(() => {
window.turnstile.render(`#${containerId}`, {
sitekey: sitekey,
theme: parentContainer ? (parentContainer.dataset.theme || 'light') : 'light',
callback: function(token) {
console.log(`Turnstile verification completed for form: ${formId} with token:`, token.substring(0, 10) + '...');
// Create or update hidden input for token
let tokenInput = form.querySelector('input[name="cf-turnstile-response"]');
if (!tokenInput) {
console.log('Creating new hidden input for Turnstile token');
tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = 'cf-turnstile-response';
form.appendChild(tokenInput);
} else {
console.log('Updating existing hidden input for Turnstile token');
}
tokenInput.value = token;
// Also add a debug attribute
form.setAttribute('data-turnstile-verified', 'true');
},
'expired-callback': function() {
console.log(`Turnstile token expired for form: ${formId}`);
},
'error-callback': function(error) {
console.error(`Turnstile error for form ${formId}: ${error}`);
}
});
}, 100);
} catch (e) {
console.error(`Error rendering Turnstile widget: ${e.message}`);
widgetContainer.innerHTML = '<p style="color:red;">Error initializing Turnstile.</p>';
}
} else {
console.warn('Turnstile API not available. Attempting to reload...');
// Remove existing script if any
const existingScript = document.querySelector('script[src*="challenges.cloudflare.com/turnstile/v0/api.js"]');
if (existingScript) {
existingScript.parentNode.removeChild(existingScript);
}
// Create new script element
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
script.async = true;
script.defer = true;
script.onload = function() {
console.log('Turnstile API loaded, retrying widget render...');
setTimeout(() => {
const retryContainer = document.querySelector('[data-captcha-provider="turnstile"]');
if (retryContainer && form) {
window.GravFormXHR.captcha.getProvider('turnstile').reset(retryContainer, form);
}
}, 200);
};
document.head.appendChild(script);
}
}
});
console.log('Turnstile XHR handler registered successfully');
} else {
console.error('GravFormXHR.captcha not found. Make sure the Form plugin is loaded correctly.');
}
};
// Try to register the handler immediately if GravFormXHR is already available
if (window.GravFormXHR && window.GravFormXHR.captcha) {
registerTurnstileHandler();
} else {
// Otherwise, wait for the DOM to be fully loaded
document.addEventListener('DOMContentLoaded', function() {
// Give a small delay to ensure GravFormXHR is initialized
setTimeout(registerTurnstileHandler, 100);
});
}
})();

View File

@@ -0,0 +1,311 @@
/**
* Direct Dropzone Initialization for XHR Forms
*
* This script directly targets Form plugin's Dropzone initialization mechanisms
*/
(function() {
'use strict';
// Enable debugging logs
const DEBUG = false;
// Helper function for logging
function log(message, type = 'log') {
if (!DEBUG) return;
const prefix = '[Dropzone Direct Init]';
if (type === 'error') {
console.error(prefix, message);
} else if (type === 'warn') {
console.warn(prefix, message);
} else {
console.log(prefix, message);
}
}
// Flag to prevent multiple initializations
let isInitializing = false;
// Function to directly initialize Dropzone
function initializeDropzone(element) {
if (isInitializing) {
log('Initialization already in progress, skipping');
return false;
}
if (!element || element.classList.contains('dz-clickable')) {
return false;
}
log('Starting direct Dropzone initialization for element:', element);
isInitializing = true;
// First, let's try to find the FilesField constructor in the global scope
if (typeof FilesField === 'function') {
log('Found FilesField constructor, trying direct instantiation');
try {
new FilesField({
container: element,
options: {}
});
log('Successfully initialized Dropzone using FilesField constructor');
isInitializing = false;
return true;
} catch (e) {
log(`Error using FilesField constructor: ${e.message}`, 'error');
// Continue with other methods
}
}
// Second approach: Look for the Form plugin's initialization code in the page
const dropzoneInit = findFunctionOnWindow('addNode') ||
window.addNode ||
findFunctionOnWindow('initDropzone');
if (dropzoneInit) {
log('Found Form plugin initialization function, calling it directly');
try {
dropzoneInit(element);
log('Successfully called Form plugin initialization function');
isInitializing = false;
return true;
} catch (e) {
log(`Error calling Form plugin initialization function: ${e.message}`, 'error');
// Continue with other methods
}
}
// Third approach: Try to invoke Dropzone directly if it's globally available
if (typeof Dropzone === 'function') {
log('Found global Dropzone constructor, trying direct instantiation');
try {
// Extract settings from the element
const settingsAttr = element.getAttribute('data-grav-file-settings');
if (!settingsAttr) {
log('No settings found for element', 'warn');
isInitializing = false;
return false;
}
const settings = JSON.parse(settingsAttr);
const optionsAttr = element.getAttribute('data-dropzone-options');
const options = optionsAttr ? JSON.parse(optionsAttr) : {};
// Configure Dropzone options
const dropzoneOptions = {
url: element.getAttribute('data-file-url-add') || window.location.href,
maxFiles: settings.limit || null,
maxFilesize: settings.filesize || 10,
acceptedFiles: settings.accept ? settings.accept.join(',') : null
};
// Merge with any provided options
Object.assign(dropzoneOptions, options);
// Create new Dropzone instance
new Dropzone(element, dropzoneOptions);
log('Successfully initialized Dropzone using global constructor');
isInitializing = false;
return true;
} catch (e) {
log(`Error using global Dropzone constructor: ${e.message}`, 'error');
// Continue to final approach
}
}
// Final approach: Force reloading of Form plugin's JavaScript
log('Attempting to force reload Form plugin JavaScript');
// Look for Form plugin's JS files
const formVendorScript = document.querySelector('script[src*="form.vendor.js"]');
const formScript = document.querySelector('script[src*="form.min.js"]');
if (formVendorScript || formScript) {
log('Found Form plugin scripts, attempting to reload them');
// Create new script elements
if (formVendorScript) {
const newVendorScript = document.createElement('script');
newVendorScript.src = formVendorScript.src.split('?')[0] + '?t=' + new Date().getTime();
newVendorScript.async = true;
newVendorScript.onload = function() {
log('Reloaded Form vendor script');
// Trigger event after script loads
setTimeout(function() {
const event = new CustomEvent('mutation._grav', {
detail: { target: element }
});
document.body.dispatchEvent(event);
}, 100);
};
document.head.appendChild(newVendorScript);
}
if (formScript) {
const newFormScript = document.createElement('script');
newFormScript.src = formScript.src.split('?')[0] + '?t=' + new Date().getTime();
newFormScript.async = true;
newFormScript.onload = function() {
log('Reloaded Form script');
// Trigger event after script loads
setTimeout(function() {
const event = new CustomEvent('mutation._grav', {
detail: { target: element }
});
document.body.dispatchEvent(event);
}, 100);
};
document.head.appendChild(newFormScript);
}
}
// As a final resort, trigger the mutation event
log('Triggering mutation._grav event as final resort');
const event = new CustomEvent('mutation._grav', {
detail: { target: element }
});
document.body.dispatchEvent(event);
isInitializing = false;
return false;
}
// Helper function to find a function on the window object by name pattern
function findFunctionOnWindow(pattern) {
for (const key in window) {
if (typeof window[key] === 'function' && key.includes(pattern)) {
return window[key];
}
}
return null;
}
// Function to check all Dropzone elements
function checkAllDropzones() {
const dropzones = document.querySelectorAll('.dropzone.files-upload:not(.dz-clickable)');
if (dropzones.length === 0) {
log('No uninitialized Dropzone elements found');
return;
}
log(`Found ${dropzones.length} uninitialized Dropzone elements`);
// Try to initialize each one
dropzones.forEach(function(element) {
initializeDropzone(element);
});
}
// Hook into form submission to reinitialize after XHR updates
function setupFormSubmissionHook() {
// First check if the XHR submit function is available
if (window.GravFormXHR && typeof window.GravFormXHR.submit === 'function') {
log('Found GravFormXHR.submit, attaching hook');
// Store the original function
const originalSubmit = window.GravFormXHR.submit;
// Override it with our version
window.GravFormXHR.submit = function(form) {
log(`XHR form submission detected for form: ${form?.id || 'unknown'}`);
// Call the original function
const result = originalSubmit.apply(this, arguments);
// Set up checks for after the submission completes
[500, 1000, 2000, 3000].forEach(function(delay) {
setTimeout(checkAllDropzones, delay);
});
return result;
};
log('Successfully hooked into GravFormXHR.submit');
}
// Also add a direct event listener for standard form submissions
document.addEventListener('submit', function(event) {
if (event.target.tagName === 'FORM') {
log(`Standard form submission detected for form: ${event.target.id || 'unknown'}`);
// Schedule checks after submission
[1000, 2000, 3000].forEach(function(delay) {
setTimeout(checkAllDropzones, delay);
});
}
});
log('Form submission hooks set up');
}
// Monitor for AJAX responses
function setupAjaxMonitoring() {
if (window.jQuery) {
log('Setting up jQuery AJAX response monitoring');
jQuery(document).ajaxComplete(function(event, xhr, settings) {
log('AJAX request completed, checking if form-related');
// Check if this looks like a form request
const url = settings.url || '';
if (url.includes('form') ||
url.includes('task=') ||
url.includes('file-upload') ||
url.includes('file-uploader')) {
log('Form-related AJAX request detected, will check for Dropzones');
// Schedule checks with delays
[300, 800, 1500].forEach(function(delay) {
setTimeout(checkAllDropzones, delay);
});
}
});
log('jQuery AJAX monitoring set up');
}
}
// Create global function for manual reinitialization
window.reinitializeDropzones = function() {
log('Manual reinitialization triggered');
checkAllDropzones();
return 'Reinitialization check triggered. See console for details.';
};
// Main initialization function
function initialize() {
log('Initializing Dropzone direct initialization system');
// Set up submission hook
setupFormSubmissionHook();
// Set up AJAX monitoring
setupAjaxMonitoring();
// Do an initial check for any uninitialized Dropzones
setTimeout(checkAllDropzones, 500);
log('Initialization complete. Use window.reinitializeDropzones() for manual reinitialization.');
}
// Start when the DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
// Delay to allow other scripts to load
setTimeout(initialize, 100);
});
} else {
// DOM already loaded, delay slightly
setTimeout(initialize, 100);
}
})();

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,662 @@
/**
* Unified Grav Form FilePond Handler
*
* This script initializes and configures FilePond instances for file uploads
* within Grav forms. It works with both normal and XHR form submissions.
* It also handles reinitializing FilePond instances after XHR form submissions.
*/
// Immediately-Invoked Function Expression for scoping
(function () {
// Check if script already loaded
if (window.gravFilepondHandlerLoaded) {
console.log('FilePond unified handler already loaded, skipping.');
return;
}
window.gravFilepondHandlerLoaded = true;
// Debugging - set to false for production
const debug = true;
// Helper function for logging
function log(message, type = 'log') {
if (!debug && type !== 'error') return;
const prefix = '[FilePond Handler]';
if (type === 'error') {
console.error(prefix, message);
} else if (type === 'warn') {
console.warn(prefix, message);
} else {
console.log(prefix, message);
}
}
// Track FilePond instances with their configuration
const pondInstances = new Map();
// Get translations from global object if available
const translations = window.GravForm?.translations?.PLUGIN_FORM || {
FILEPOND_REMOVE_FILE: 'Remove file',
FILEPOND_REMOVE_FILE_CONFIRMATION: 'Are you sure you want to remove this file?',
FILEPOND_CANCEL_UPLOAD: 'Cancel upload',
FILEPOND_ERROR_FILESIZE: 'File is too large',
FILEPOND_ERROR_FILETYPE: 'Invalid file type'
};
// Track initialization state
let initialized = false;
/**
* Get standard FilePond configuration for an element
* This is used for both initial setup and reinit after XHR
* @param {HTMLElement} element - The file input element
* @param {HTMLElement} container - The container element
* @returns {Object} Configuration object for FilePond
*/
function getFilepondConfig(element, container) {
if (!container) {
log('Container not provided for config extraction', 'error');
return null;
}
// Check if the field is required - this is correct location
const isRequired = element.hasAttribute('required') ||
container.hasAttribute('required') ||
container.getAttribute('data-required') === 'true';
// Then, add this code to remove the required attribute from the actual input
// to prevent browser validation errors, but keep track of the requirement
if (isRequired) {
// Store the required state on the container for our custom validation
container.setAttribute('data-required', 'true');
// Remove the required attribute from the input to avoid browser validation errors
element.removeAttribute('required');
}
try {
// Get settings from data attributes
const settingsAttr = container.getAttribute('data-grav-file-settings');
if (!settingsAttr) {
log('No file settings found for FilePond element', 'warn');
return null;
}
// Parse settings
let settings;
try {
settings = JSON.parse(settingsAttr);
log('Parsed settings:', settings);
} catch (e) {
log(`Error parsing file settings: ${e.message}`, 'error');
return null;
}
// Parse FilePond options
const filepondOptionsAttr = container.getAttribute('data-filepond-options') || '{}';
let filepondOptions;
try {
filepondOptions = JSON.parse(filepondOptionsAttr);
log('Parsed FilePond options:', filepondOptions);
} catch (e) {
log(`Error parsing FilePond options: ${e.message}`, 'error');
filepondOptions = {};
}
// Get URLs for upload and remove
const uploadUrl = container.getAttribute('data-file-url-add');
const removeUrl = container.getAttribute('data-file-url-remove');
if (!uploadUrl) {
log('Upload URL not found for FilePond element', 'warn');
return null;
}
// Parse previously uploaded files
const existingFiles = [];
const fileDataElements = container.querySelectorAll('[data-file]');
log(`Found ${fileDataElements.length} existing file data elements`);
fileDataElements.forEach(fileData => {
try {
const fileAttr = fileData.getAttribute('data-file');
log('File data attribute:', fileAttr);
const fileJson = JSON.parse(fileAttr);
if (fileJson && fileJson.name) {
existingFiles.push({
source: fileJson.name,
options: {
type: 'local',
file: {
name: fileJson.name,
size: fileJson.size,
type: fileJson.type
},
metadata: {
poster: fileJson.thumb_url || fileJson.path
}
}
});
}
} catch (e) {
log(`Error parsing file data: ${e.message}`, 'error');
}
});
log('Existing files:', existingFiles);
// Get form elements for Grav integration
const fieldName = container.getAttribute('data-file-field-name');
const form = element.closest('form');
const formNameInput = form ? form.querySelector('[name="__form-name__"]') : document.querySelector('[name="__form-name__"]');
const formIdInput = form ? form.querySelector('[name="__unique_form_id__"]') : document.querySelector('[name="__unique_form_id__"]');
const formNonceInput = form ? form.querySelector('[name="form-nonce"]') : document.querySelector('[name="form-nonce"]');
if (!formNameInput || !formIdInput || !formNonceInput) {
log('Missing required form inputs for proper Grav integration', 'warn');
}
// Configure FilePond
const options = {
// Core settings
name: settings.paramName,
maxFiles: settings.limit || null,
maxFileSize: `${settings.filesize}MB`,
acceptedFileTypes: settings.accept,
files: existingFiles,
// Server configuration - modified for Grav
server: {
process: {
url: uploadUrl,
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
ondata: (formData) => {
// Safety check - ensure formData is valid
if (!formData) {
console.error('FormData is undefined in ondata');
return new FormData(); // Return empty FormData as fallback
}
// Add all required Grav form fields
if (formNameInput) formData.append('__form-name__', formNameInput.value);
if (formIdInput) formData.append('__unique_form_id__', formIdInput.value);
formData.append('__form-file-uploader__', '1');
if (formNonceInput) formData.append('form-nonce', formNonceInput.value);
formData.append('task', 'filesupload');
// Use fieldName from the outer scope
if (fieldName) {
formData.append('name', fieldName);
} else {
console.error('Field name is undefined, falling back to default');
formData.append('name', 'files');
}
// Add URI if needed
const uriInput = document.querySelector('[name="uri"]');
if (uriInput) {
formData.append('uri', uriInput.value);
}
// Note: Don't try to append file here, FilePond will do that based on the name parameter
// Just return the modified formData
log('Prepared form data for Grav upload');
return formData;
}
},
revert: removeUrl ? {
url: removeUrl,
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
ondata: (formData, file) => {
// Add all required Grav form fields
if (formNameInput) formData.append('__form-name__', formNameInput.value);
if (formIdInput) formData.append('__unique_form_id__', formIdInput.value);
formData.append('__form-file-remover__', '1');
if (formNonceInput) formData.append('form-nonce', formNonceInput.value);
formData.append('name', fieldName);
// Add filename
formData.append('filename', file.filename);
log('Prepared form data for file removal');
return formData;
}
} : null
},
// Image Transform settings - both FilePond native settings and our custom ones
// Native settings
allowImagePreview: true,
allowImageResize: true,
allowImageTransform: true,
imagePreviewHeight: filepondOptions.imagePreviewHeight || 256,
// Transform settings
imageTransformOutputMimeType: filepondOptions.imageTransformOutputMimeType || 'image/jpeg',
imageTransformOutputQuality: filepondOptions.imageTransformOutputQuality || settings.resizeQuality || 90,
imageTransformOutputStripImageHead: filepondOptions.imageTransformOutputStripImageHead !== false,
// Resize settings
imageResizeTargetWidth: filepondOptions.imageResizeTargetWidth || settings.resizeWidth || null,
imageResizeTargetHeight: filepondOptions.imageResizeTargetHeight || settings.resizeHeight || null,
imageResizeMode: filepondOptions.imageResizeMode || 'cover',
imageResizeUpscale: filepondOptions.imageResizeUpscale || false,
// Crop settings
allowImageCrop: filepondOptions.allowImageCrop || false,
imageCropAspectRatio: filepondOptions.imageCropAspectRatio || null,
// Labels and translations
labelIdle: filepondOptions.labelIdle || '<span class="filepond--label-action">Browse</span> or drop files',
labelFileTypeNotAllowed: translations.FILEPOND_ERROR_FILETYPE || 'Invalid file type',
labelFileSizeNotAllowed: translations.FILEPOND_ERROR_FILESIZE || 'File is too large',
labelFileLoading: 'Loading',
labelFileProcessing: 'Uploading',
labelFileProcessingComplete: 'Upload complete',
labelFileProcessingAborted: 'Upload cancelled',
labelTapToCancel: translations.FILEPOND_CANCEL_UPLOAD || 'Cancel upload',
labelTapToRetry: 'Retry',
labelTapToUndo: 'Undo',
labelButtonRemoveItem: translations.FILEPOND_REMOVE_FILE || 'Remove',
// Style settings
stylePanelLayout: filepondOptions.stylePanelLayout || 'compact',
styleLoadIndicatorPosition: filepondOptions.styleLoadIndicatorPosition || 'center bottom',
styleProgressIndicatorPosition: filepondOptions.styleProgressIndicatorPosition || 'center bottom',
styleButtonRemoveItemPosition: filepondOptions.styleButtonRemoveItemPosition || 'right',
// Override with any remaining user-provided options
...filepondOptions
};
log('Prepared FilePond configuration:', options);
return options;
} catch (e) {
log(`Error creating FilePond configuration: ${e.message}`, 'error');
console.error(e); // Full error in console
return null;
}
}
/**
* Initialize a single FilePond instance
* @param {HTMLElement} element - The file input element to initialize
* @returns {FilePond|null} The created FilePond instance, or null if creation failed
*/
function initializeSingleFilePond(element) {
const container = element.closest('.filepond-root');
if (!container) {
log('FilePond container not found for input element', 'error');
return null;
}
// Don't initialize twice
if (container.classList.contains('filepond--hopper') || container.querySelector('.filepond--hopper')) {
log('FilePond already initialized for this element, skipping');
return null;
}
// Get the element ID or create a unique one for tracking
const elementId = element.id || `filepond-${Math.random().toString(36).substring(2, 15)}`;
// Get configuration
const config = getFilepondConfig(element, container);
if (!config) {
log('Failed to get configuration, cannot initialize FilePond', 'error');
return null;
}
log(`Initializing FilePond element ${elementId} with config`, config);
try {
// Create FilePond instance
const pond = FilePond.create(element, config);
log(`FilePond instance created successfully for element ${elementId}`);
// Store the instance and its configuration for potential reinit
pondInstances.set(elementId, {
instance: pond,
config: config,
container: container
});
// Add a reference to the element for easier lookup
element.filepondId = elementId;
container.filepondId = elementId;
// Handle form submission to ensure files are processed before submit
const form = element.closest('form');
if (form && !form._filepond_handler_attached) {
form._filepond_handler_attached = true;
form.addEventListener('submit', function (e) {
// Check for all FilePond instances in this form
const formPonds = Array.from(pondInstances.values())
.filter(info => info.instance && info.container.closest('form') === form);
const processingFiles = formPonds.reduce((total, info) => {
return total + info.instance.getFiles().filter(file =>
file.status === FilePond.FileStatus.PROCESSING_QUEUED ||
file.status === FilePond.FileStatus.PROCESSING
).length;
}, 0);
if (processingFiles > 0) {
e.preventDefault();
alert('Please wait for all files to finish uploading before submitting the form.');
return false;
}
});
}
return pond;
} catch (e) {
log(`Error creating FilePond instance: ${e.message}`, 'error');
console.error(e); // Full error in console
return null;
}
}
/**
* Main FilePond initialization function
* This will find and initialize all uninitialized FilePond elements
*/
function initializeFilePond() {
log('Starting FilePond initialization');
// Make sure we have the libraries loaded
if (typeof window.FilePond === 'undefined') {
log('FilePond library not found. Will retry in 500ms...', 'warn');
setTimeout(initializeFilePond, 500);
return;
}
log('FilePond library found, continuing initialization');
// Register plugins if available
try {
if (window.FilePondPluginFileValidateSize) {
FilePond.registerPlugin(FilePondPluginFileValidateSize);
log('Registered FileValidateSize plugin');
}
if (window.FilePondPluginFileValidateType) {
FilePond.registerPlugin(FilePondPluginFileValidateType);
log('Registered FileValidateType plugin');
}
if (window.FilePondPluginImagePreview) {
FilePond.registerPlugin(FilePondPluginImagePreview);
log('Registered ImagePreview plugin');
}
if (window.FilePondPluginImageResize) {
FilePond.registerPlugin(FilePondPluginImageResize);
log('Registered ImageResize plugin');
}
if (window.FilePondPluginImageTransform) {
FilePond.registerPlugin(FilePondPluginImageTransform);
log('Registered ImageTransform plugin');
}
} catch (e) {
log(`Error registering plugins: ${e.message}`, 'error');
}
// Find all FilePond elements
const elements = document.querySelectorAll('.filepond-root input[type="file"]:not(.filepond--browser)');
if (elements.length === 0) {
log('No FilePond form elements found on the page');
return;
}
log(`Found ${elements.length} FilePond element(s)`);
// Process each FilePond element
elements.forEach((element, index) => {
log(`Initializing FilePond element #${index + 1}`);
initializeSingleFilePond(element);
});
initialized = true;
log('FilePond initialization complete');
}
/**
* Reinitialize a specific FilePond instance
* @param {HTMLElement} container - The FilePond container element
* @returns {FilePond|null} The reinitialized FilePond instance, or null if reinitialization failed
*/
function reinitializeSingleFilePond(container) {
if (!container) {
log('No container provided for reinitialization', 'error');
return null;
}
// Check if this is a FilePond container
if (!container.classList.contains('filepond-root')) {
log('Container is not a FilePond container', 'warn');
return null;
}
log(`Reinitializing FilePond container: ${container.id || 'unnamed'}`);
// If already initialized, destroy first
if (container.classList.contains('filepond--hopper') || container.querySelector('.filepond--hopper')) {
log('Container already has an active FilePond instance, destroying it first');
// Try to find and destroy through our internal tracking
const elementId = container.filepondId;
if (elementId && pondInstances.has(elementId)) {
const info = pondInstances.get(elementId);
if (info.instance) {
log(`Destroying tracked FilePond instance for element ${elementId}`);
info.instance.destroy();
pondInstances.delete(elementId);
}
} else {
// Fallback: Try to find via child element with class
const pondElement = container.querySelector('.filepond--root');
if (pondElement && pondElement._pond) {
log('Destroying FilePond instance via DOM reference');
pondElement._pond.destroy();
}
}
}
// Look for the file input
const input = container.querySelector('input[type="file"]:not(.filepond--browser)');
if (!input) {
log('No file input found in container for reinitialization', 'error');
return null;
}
// Create a new instance
return initializeSingleFilePond(input);
}
/**
* Reinitialize all FilePond instances
* This is used after XHR form submissions
*/
function reinitializeFilePond() {
log('Reinitializing all FilePond instances');
// Find all FilePond containers
const containers = document.querySelectorAll('.filepond-root');
if (containers.length === 0) {
log('No FilePond containers found for reinitialization');
return;
}
log(`Found ${containers.length} FilePond container(s) for reinitialization`);
// Process each container
containers.forEach((container, index) => {
log(`Reinitializing FilePond container #${index + 1}`);
reinitializeSingleFilePond(container);
});
log('FilePond reinitialization complete');
}
/**
* Helper function to support XHR form interaction
* This hooks into the GravFormXHR system if available
*/
function setupXHRIntegration() {
// Only run if GravFormXHR is available
if (window.GravFormXHR) {
log('Setting up XHR integration for FilePond');
// Store original submit function
const originalSubmit = window.GravFormXHR.submit;
// Override to handle FilePond files
window.GravFormXHR.submit = function (form) {
if (!form) {
return originalSubmit.apply(this, arguments);
}
// Check for any FilePond instances in the form
let hasPendingUploads = false;
// First check via our tracking
Array.from(pondInstances.values()).forEach(info => {
if (info.container.closest('form') === form) {
const processingFiles = info.instance.getFiles().filter(file =>
file.status === FilePond.FileStatus.PROCESSING_QUEUED ||
file.status === FilePond.FileStatus.PROCESSING);
if (processingFiles.length > 0) {
hasPendingUploads = true;
}
}
});
// Fallback check for any untracked instances
if (!hasPendingUploads) {
const filepondContainers = form.querySelectorAll('.filepond-root');
filepondContainers.forEach(container => {
const pondElement = container.querySelector('.filepond--root');
if (pondElement && pondElement._pond) {
const pond = pondElement._pond;
const processingFiles = pond.getFiles().filter(file =>
file.status === FilePond.FileStatus.PROCESSING_QUEUED ||
file.status === FilePond.FileStatus.PROCESSING);
if (processingFiles.length > 0) {
hasPendingUploads = true;
}
}
});
}
if (hasPendingUploads) {
alert('Please wait for all files to finish uploading before submitting the form.');
return false;
}
// Call the original submit function
return originalSubmit.apply(this, arguments);
};
// Set up listeners for form updates
document.addEventListener('grav-form-updated', function (e) {
log('Detected form update event, reinitializing FilePond instances');
setTimeout(reinitializeFilePond, 100);
});
}
}
/**
* Setup mutation observer to detect dynamically added FilePond elements
*/
function setupMutationObserver() {
if (window.MutationObserver) {
const observer = new MutationObserver((mutations) => {
let shouldCheck = false;
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
if (node.classList && node.classList.contains('filepond-root') ||
node.querySelector && node.querySelector('.filepond-root')) {
shouldCheck = true;
break;
}
}
}
}
if (shouldCheck) break;
}
if (shouldCheck) {
log('DOM changes detected that might include FilePond elements');
// Delay to ensure DOM is fully updated
setTimeout(initializeFilePond, 50);
}
});
// Start observing
observer.observe(document.body, {
childList: true,
subtree: true
});
log('MutationObserver set up for FilePond elements');
}
}
/**
* Initialize when DOM is ready
*/
function domReadyInit() {
log('DOM ready, initializing FilePond');
initializeFilePond();
setupXHRIntegration();
setupMutationObserver();
}
// Handle different document ready states
if (document.readyState === 'loading') {
log('Document still loading, adding DOMContentLoaded listener');
document.addEventListener('DOMContentLoaded', domReadyInit);
} else {
log('Document already loaded, initializing now');
setTimeout(domReadyInit, 0);
}
// Also support initialization via window load event as a fallback
window.addEventListener('load', function () {
log('Window load event fired');
if (!initialized) {
log('FilePond not yet initialized, initializing now');
initializeFilePond();
}
});
// Expose functions to global scope for external usage
window.GravFilePond = {
initialize: initializeFilePond,
reinitialize: reinitializeFilePond,
reinitializeContainer: reinitializeSingleFilePond,
getInstances: () => Array.from(pondInstances.values()).map(info => info.instance)
};
// Log initialization start
log('FilePond unified handler script loaded and ready');
})();

View File

@@ -0,0 +1,141 @@
/**
* FilePond Direct Fix - Emergency fix for XHR forms
*/
(function() {
// Directly attempt to initialize uninitialized FilePond elements
// without relying on any existing logic
console.log('FilePond Direct Fix loaded');
// Function to directly create FilePond instances
function initializeFilePondElements() {
console.log('Direct FilePond initialization attempt');
// Find uninitialized FilePond elements
const elements = document.querySelectorAll('.filepond-root:not(.filepond--hopper)');
if (elements.length === 0) {
return;
}
console.log(`Found ${elements.length} uninitialized FilePond elements`);
// Process each element
elements.forEach((element, index) => {
const input = element.querySelector('input[type="file"]:not(.filepond--browser)');
if (!input) {
console.log(`Element #${index + 1}: No suitable file input found`);
return;
}
console.log(`Element #${index + 1}: Found file input:`, input);
// Get settings
let settings = {};
try {
const settingsAttr = element.getAttribute('data-grav-file-settings');
if (settingsAttr) {
settings = JSON.parse(settingsAttr);
console.log('Parsed settings:', settings);
}
} catch (e) {
console.error('Failed to parse settings:', e);
}
// Get URLS
const uploadUrl = element.getAttribute('data-file-url-add');
const removeUrl = element.getAttribute('data-file-url-remove');
console.log('Upload URL:', uploadUrl);
console.log('Remove URL:', removeUrl);
try {
// Create FilePond instance directly
const pond = FilePond.create(input);
// Apply minimal configuration to make uploads work
if (pond) {
console.log(`Successfully created FilePond on element #${index + 1}`);
// Basic configuration to make it functional
pond.setOptions({
name: settings.paramName || input.name || 'files',
server: {
process: uploadUrl,
revert: removeUrl
},
// Transform options
imageTransformOutputMimeType: 'image/jpeg',
imageTransformOutputQuality: settings.resizeQuality || 90,
imageTransformOutputStripImageHead: true,
// Resize options
imageResizeTargetWidth: settings.resizeWidth || null,
imageResizeTargetHeight: settings.resizeHeight || null,
imageResizeMode: 'cover',
imageResizeUpscale: false
});
}
} catch (e) {
console.error(`Failed to create FilePond on element #${index + 1}:`, e);
}
});
}
// Monitor form submissions and DOM changes
function setupMonitoring() {
// Create MutationObserver to watch for DOM changes
if (window.MutationObserver) {
const observer = new MutationObserver((mutations) => {
let shouldCheck = false;
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
if (node.classList && node.classList.contains('filepond-root') ||
node.querySelector && node.querySelector('.filepond-root')) {
shouldCheck = true;
break;
}
}
}
}
if (shouldCheck) break;
}
if (shouldCheck) {
console.log('DOM changes detected that might include FilePond elements');
// Delay to ensure DOM is fully updated
setTimeout(initializeFilePondElements, 50);
}
});
// Start observing
observer.observe(document.body, {
childList: true,
subtree: true
});
console.log('MutationObserver set up for FilePond elements');
}
}
// Set up the emergency fix
function init() {
// Set up monitoring
setupMonitoring();
// Expose global function for manual reinit
window.directFilePondInit = initializeFilePondElements;
// Initial check
setTimeout(initializeFilePondElements, 500);
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
setTimeout(init, 0);
}
})();

View File

@@ -0,0 +1,9 @@
/*!
* FilePondPluginFileValidateSize 2.2.8
* Licensed under MIT, https://opensource.org/licenses/MIT/
* Please visit https://pqina.nl/filepond/ for details.
*/
/* eslint-disable */
!function(e,i){"object"==typeof exports&&"undefined"!=typeof module?module.exports=i():"function"==typeof define&&define.amd?define(i):(e=e||self).FilePondPluginFileValidateSize=i()}(this,function(){"use strict";var e=function(e){var i=e.addFilter,E=e.utils,l=E.Type,_=E.replaceInString,n=E.toNaturalFileSize;return i("ALLOW_HOPPER_ITEM",function(e,i){var E=i.query;if(!E("GET_ALLOW_FILE_SIZE_VALIDATION"))return!0;var l=E("GET_MAX_FILE_SIZE");if(null!==l&&e.size>l)return!1;var _=E("GET_MIN_FILE_SIZE");return!(null!==_&&e.size<_)}),i("LOAD_FILE",function(e,i){var E=i.query;return new Promise(function(i,l){if(!E("GET_ALLOW_FILE_SIZE_VALIDATION"))return i(e);var I=E("GET_FILE_VALIDATE_SIZE_FILTER");if(I&&!I(e))return i(e);var t=E("GET_MAX_FILE_SIZE");if(null!==t&&e.size>t)l({status:{main:E("GET_LABEL_MAX_FILE_SIZE_EXCEEDED"),sub:_(E("GET_LABEL_MAX_FILE_SIZE"),{filesize:n(t,".",E("GET_FILE_SIZE_BASE"),E("GET_FILE_SIZE_LABELS",E))})}});else{var L=E("GET_MIN_FILE_SIZE");if(null!==L&&e.size<L)l({status:{main:E("GET_LABEL_MIN_FILE_SIZE_EXCEEDED"),sub:_(E("GET_LABEL_MIN_FILE_SIZE"),{filesize:n(L,".",E("GET_FILE_SIZE_BASE"),E("GET_FILE_SIZE_LABELS",E))})}});else{var a=E("GET_MAX_TOTAL_FILE_SIZE");if(null!==a)if(E("GET_ACTIVE_ITEMS").reduce(function(e,i){return e+i.fileSize},0)>a)return void l({status:{main:E("GET_LABEL_MAX_TOTAL_FILE_SIZE_EXCEEDED"),sub:_(E("GET_LABEL_MAX_TOTAL_FILE_SIZE"),{filesize:n(a,".",E("GET_FILE_SIZE_BASE"),E("GET_FILE_SIZE_LABELS",E))})}});i(e)}}})}),{options:{allowFileSizeValidation:[!0,l.BOOLEAN],maxFileSize:[null,l.INT],minFileSize:[null,l.INT],maxTotalFileSize:[null,l.INT],fileValidateSizeFilter:[null,l.FUNCTION],labelMinFileSizeExceeded:["File is too small",l.STRING],labelMinFileSize:["Minimum file size is {filesize}",l.STRING],labelMaxFileSizeExceeded:["File is too large",l.STRING],labelMaxFileSize:["Maximum file size is {filesize}",l.STRING],labelMaxTotalFileSizeExceeded:["Maximum total size exceeded",l.STRING],labelMaxTotalFileSize:["Maximum total file size is {filesize}",l.STRING]}}};return"undefined"!=typeof window&&void 0!==window.document&&document.dispatchEvent(new CustomEvent("FilePond:pluginloaded",{detail:e})),e});

View File

@@ -0,0 +1,9 @@
/*!
* FilePondPluginFileValidateType 1.2.9
* Licensed under MIT, https://opensource.org/licenses/MIT/
* Please visit https://pqina.nl/filepond/ for details.
*/
/* eslint-disable */
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).FilePondPluginFileValidateType=t()}(this,function(){"use strict";var e=function(e){var t=e.addFilter,n=e.utils,i=n.Type,T=n.isString,E=n.replaceInString,l=n.guesstimateMimeType,o=n.getExtensionFromFilename,r=n.getFilenameFromURL,u=function(e,t){return e.some(function(e){return/\*$/.test(e)?(n=e,(/^[^/]+/.exec(t)||[]).pop()===n.slice(0,-2)):e===t;var n})},a=function(e,t,n){if(0===t.length)return!0;var i=function(e){var t="";if(T(e)){var n=r(e),i=o(n);i&&(t=l(i))}else t=e.type;return t}(e);return n?new Promise(function(T,E){n(e,i).then(function(e){u(t,e)?T():E()}).catch(E)}):u(t,i)};return t("SET_ATTRIBUTE_TO_OPTION_MAP",function(e){return Object.assign(e,{accept:"acceptedFileTypes"})}),t("ALLOW_HOPPER_ITEM",function(e,t){var n=t.query;return!n("GET_ALLOW_FILE_TYPE_VALIDATION")||a(e,n("GET_ACCEPTED_FILE_TYPES"))}),t("LOAD_FILE",function(e,t){var n=t.query;return new Promise(function(t,i){if(n("GET_ALLOW_FILE_TYPE_VALIDATION")){var T=n("GET_ACCEPTED_FILE_TYPES"),l=n("GET_FILE_VALIDATE_TYPE_DETECT_TYPE"),o=a(e,T,l),r=function(){var e,t=T.map((e=n("GET_FILE_VALIDATE_TYPE_LABEL_EXPECTED_TYPES_MAP"),function(t){return null!==e[t]&&(e[t]||t)})).filter(function(e){return!1!==e}),l=t.filter(function(e,n){return t.indexOf(e)===n});i({status:{main:n("GET_LABEL_FILE_TYPE_NOT_ALLOWED"),sub:E(n("GET_FILE_VALIDATE_TYPE_LABEL_EXPECTED_TYPES"),{allTypes:l.join(", "),allButLastType:l.slice(0,-1).join(", "),lastType:l[l.length-1]})}})};if("boolean"==typeof o)return o?t(e):r();o.then(function(){t(e)}).catch(r)}else t(e)})}),{options:{allowFileTypeValidation:[!0,i.BOOLEAN],acceptedFileTypes:[[],i.ARRAY],labelFileTypeNotAllowed:["File is of invalid type",i.STRING],fileValidateTypeLabelExpectedTypes:["Expects {allButLastType} or {lastType}",i.STRING],fileValidateTypeLabelExpectedTypesMap:[{},i.OBJECT],fileValidateTypeDetectType:[null,i.FUNCTION]}}};return"undefined"!=typeof window&&void 0!==window.document&&document.dispatchEvent(new CustomEvent("FilePond:pluginloaded",{detail:e})),e});

View File

@@ -0,0 +1,8 @@
/*!
* FilePondPluginImagePreview 4.6.12
* Licensed under MIT, https://opensource.org/licenses/MIT/
* Please visit https://pqina.nl/filepond/ for details.
*/
/* eslint-disable */
.filepond--image-preview-markup{position:absolute;left:0;top:0}.filepond--image-preview-wrapper{z-index:2}.filepond--image-preview-overlay{display:block;position:absolute;left:0;top:0;width:100%;min-height:5rem;max-height:7rem;margin:0;opacity:0;z-index:2;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.filepond--image-preview-overlay svg{width:100%;height:auto;color:inherit;max-height:inherit}.filepond--image-preview-overlay-idle{mix-blend-mode:multiply;color:rgba(40,40,40,.85)}.filepond--image-preview-overlay-success{mix-blend-mode:normal;color:#369763}.filepond--image-preview-overlay-failure{mix-blend-mode:normal;color:#c44e47}@supports (-webkit-marquee-repetition:infinite) and ((-o-object-fit:fill) or (object-fit:fill)){.filepond--image-preview-overlay-idle{mix-blend-mode:normal}}.filepond--image-preview-wrapper{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;position:absolute;left:0;top:0;right:0;height:100%;margin:0;border-radius:.45em;overflow:hidden;background:rgba(0,0,0,.01)}.filepond--image-preview{position:absolute;left:0;top:0;z-index:1;display:flex;align-items:center;height:100%;width:100%;pointer-events:none;background:#222;will-change:transform,opacity}.filepond--image-clip{position:relative;overflow:hidden;margin:0 auto}.filepond--image-clip[data-transparency-indicator=grid] canvas,.filepond--image-clip[data-transparency-indicator=grid] img{background-color:#fff;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg' fill='%23eee'%3E%3Cpath d='M0 0h50v50H0M50 50h50v50H50'/%3E%3C/svg%3E");background-size:1.25em 1.25em}.filepond--image-bitmap,.filepond--image-vector{position:absolute;left:0;top:0;will-change:transform}.filepond--root[data-style-panel-layout~=integrated] .filepond--image-preview-wrapper{border-radius:0}.filepond--root[data-style-panel-layout~=integrated] .filepond--image-preview{height:100%;display:flex;justify-content:center;align-items:center}.filepond--root[data-style-panel-layout~=circle] .filepond--image-preview-wrapper{border-radius:99999rem}.filepond--root[data-style-panel-layout~=circle] .filepond--image-preview-overlay{top:auto;bottom:0;-webkit-transform:scaleY(-1);transform:scaleY(-1)}.filepond--root[data-style-panel-layout~=circle] .filepond--file .filepond--file-action-button[data-align*=bottom]:not([data-align*=center]){margin-bottom:.325em}.filepond--root[data-style-panel-layout~=circle] .filepond--file [data-align*=left]{left:calc(50% - 3em)}.filepond--root[data-style-panel-layout~=circle] .filepond--file [data-align*=right]{right:calc(50% - 3em)}.filepond--root[data-style-panel-layout~=circle] .filepond--progress-indicator[data-align*=bottom][data-align*=left],.filepond--root[data-style-panel-layout~=circle] .filepond--progress-indicator[data-align*=bottom][data-align*=right]{margin-bottom:.5125em}.filepond--root[data-style-panel-layout~=circle] .filepond--progress-indicator[data-align*=bottom][data-align*=center]{margin-top:0;margin-bottom:.1875em;margin-left:.1875em}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,9 @@
/*!
* FilePondPluginImageResize 2.0.10
* Licensed under MIT, https://opensource.org/licenses/MIT/
* Please visit https://pqina.nl/filepond/ for details.
*/
/* eslint-disable */
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).FilePondPluginImageResize=t()}(this,function(){"use strict";var e=function(e){var t=e.addFilter,i=e.utils.Type;return t("DID_LOAD_ITEM",function(e,t){var i=t.query;return new Promise(function(t,n){var r=e.file;if(!function(e){return/^image/.test(e.type)}(r)||!i("GET_ALLOW_IMAGE_RESIZE"))return t(e);var u=i("GET_IMAGE_RESIZE_MODE"),o=i("GET_IMAGE_RESIZE_TARGET_WIDTH"),a=i("GET_IMAGE_RESIZE_TARGET_HEIGHT"),l=i("GET_IMAGE_RESIZE_UPSCALE");if(null===o&&null===a)return t(e);var d,f,E,s=null===o?a:o,c=null===a?s:a,I=URL.createObjectURL(r);d=I,f=function(i){if(URL.revokeObjectURL(I),!i)return t(e);var n=i.width,r=i.height,o=(e.getMetadata("exif")||{}).orientation||-1;if(o>=5&&o<=8){var a=[r,n];n=a[0],r=a[1]}if(n===s&&r===c)return t(e);if(!l)if("cover"===u){if(n<=s||r<=c)return t(e)}else if(n<=s&&r<=s)return t(e);e.setMetadata("resize",{mode:u,upscale:l,size:{width:s,height:c}}),t(e)},(E=new Image).onload=function(){var e=E.naturalWidth,t=E.naturalHeight;E=null,f({width:e,height:t})},E.onerror=function(){return f(null)},E.src=d})}),{options:{allowImageResize:[!0,i.BOOLEAN],imageResizeMode:["cover",i.STRING],imageResizeUpscale:[!0,i.BOOLEAN],imageResizeTargetWidth:[null,i.INT],imageResizeTargetHeight:[null,i.INT]}}};return"undefined"!=typeof window&&void 0!==window.document&&document.dispatchEvent(new CustomEvent("FilePond:pluginloaded",{detail:e})),e});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.form-group.has-errors{background:rgba(255,0,0,.05);border:1px solid rgba(255,0,0,.2);border-radius:3px;margin:0 -5px;padding:0 5px}.form-errors{color:#b52b27}.form-honeybear{display:none;position:absolute !important;height:1px;width:1px;overflow:hidden;clip-path:rect(0px, 1px, 1px, 0px)}.form-errors p{margin:0}.form-input-file input{display:none}.form-input-file .dz-default.dz-message{position:absolute;text-align:center;left:0;right:0;top:50%;transform:translateY(-50%);margin:0}.form-input-file.dropzone{position:relative;min-height:70px;border-radius:3px;margin-bottom:.85rem;border:2px dashed #ccc;color:#aaa;padding:.5rem}.form-input-file.dropzone .dz-preview{margin:.5rem}.form-input-file.dropzone .dz-preview:hover{z-index:2}.form-input-file.dropzone .dz-preview .dz-image img{margin:0}.form-input-file.dropzone .dz-preview .dz-remove{font-size:16px;position:absolute;top:3px;right:3px;display:inline-flex;height:20px;width:20px;background-color:red;justify-content:center;align-items:center;color:#fff;font-weight:bold;border-radius:50%;cursor:pointer;z-index:20}.form-input-file.dropzone .dz-preview .dz-remove:hover{background-color:darkred;text-decoration:none}.form-input-file.dropzone .dz-preview .dz-error-message{min-width:140px;width:auto}.form-input-file.dropzone .dz-preview .dz-image,.form-input-file.dropzone .dz-preview.dz-file-preview .dz-image{border-radius:3px;z-index:1}.filepond--root.form-input{min-height:7rem;height:auto;overflow:hidden;border:0}.form-tabs .tabs-nav{display:flex;padding-top:1px;margin-bottom:-1px}.form-tabs .tabs-nav a{flex:1;transition:color .5s ease,background .5s ease;cursor:pointer;text-align:center;padding:10px;display:flex;align-items:center;justify-content:center;border-bottom:1px solid #ccc;border-radius:5px 5px 0 0}.form-tabs .tabs-nav a.active{border:1px solid #ccc;border-bottom:1px solid rgba(0,0,0,0);margin:0 -1px}.form-tabs .tabs-nav a.active span{color:#000}.form-tabs .tabs-nav span{display:inline-block;line-height:1.1}.form-tabs.subtle .tabs-nav{margin-right:0 !important}.form-tabs .tabs-content .tab__content{display:none;padding-top:2rem}.form-tabs .tabs-content .tab__content.active{display:block}.checkboxes{display:inline-block}.checkboxes label{display:inline;cursor:pointer;position:relative;padding:0 0 0 20px;margin-right:15px}.checkboxes label:before{content:"";display:inline-block;width:20px;height:20px;left:0;margin-top:0;margin-right:10px;position:absolute;border-radius:3px;border:1px solid #e6e6e6}.checkboxes input[type=checkbox]{display:none}.checkboxes input[type=checkbox]:checked+label:before{content:"✓";font-size:20px;line-height:1;text-align:center}.checkboxes.toggleable label{margin-right:0}.form-field-toggleable .checkboxes.toggleable{margin-right:5px;vertical-align:middle}.form-field-toggleable .checkboxes+label{display:inline-block}.switch-toggle{display:inline-flex;overflow:hidden;border-radius:3px;line-height:35px;border:1px solid #ccc}.switch-toggle input[type=radio]{position:absolute;visibility:hidden;display:none}.switch-toggle label{display:inline-block;cursor:pointer;padding:0 15px;margin:0;white-space:nowrap;color:inherit;transition:background-color .5s ease}.switch-toggle input.highlight:checked+label{background:#333;color:#fff}.switch-toggle input:checked+label{color:#fff;background:#999}.signature-pad{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;font-size:10px;width:100%;height:100%;max-width:700px;max-height:460px;border:1px solid #f0f0f0;background-color:#fff;padding:16px}.signature-pad--body{position:relative;-webkit-box-flex:1;-ms-flex:1;flex:1;border:1px solid #f6f6f6;min-height:100px}.signature-pad--body canvas{position:absolute;left:0;top:0;width:100%;height:100%;border-radius:4px;box-shadow:0 0 5px rgba(0,0,0,.02) inset}.signature-pad--footer{color:#c3c3c3;text-align:center;font-size:1.2em}.signature-pad--actions{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;margin-top:8px}[data-grav-field=array] .form-row{display:flex;align-items:center;margin-bottom:.5rem}[data-grav-field=array] .form-row>input,[data-grav-field=array] .form-row>textarea{margin:0 .5rem;display:inline-block}.form-data.basic-captcha .form-input-wrapper{border:1px solid #ccc;border-radius:5px;display:flex;overflow:hidden}.form-data.basic-captcha .form-input-prepend{display:flex;color:#333;background-color:#ccc;flex-shrink:0}.form-data.basic-captcha .form-input-prepend img{margin:0}.form-data.basic-captcha .form-input-prepend button>svg{margin:0 8px;width:18px;height:18px}.form-data.basic-captcha input.form-input{border:0}/*# sourceMappingURL=form-styles.css.map */

View File

@@ -0,0 +1 @@
{"version":3,"sourceRoot":"","sources":["../scss/form-styles.scss"],"names":[],"mappings":"CAGA,uBACI,6BACA,kCACA,kBACA,cACA,cAGJ,aACI,cAGJ,gBACI,aACA,6BACA,WACA,UACA,gBACA,mCAGJ,eACI,SAKA,uBACI,aAGJ,wCACI,kBACA,kBACA,OACA,QACA,QACA,2BACA,SAGJ,0BACI,kBACA,gBACA,kBACA,qBACA,uBACA,WACA,cAEA,sCACI,aAEA,4CACI,UAGJ,oDACE,SAGF,iDACE,eACA,kBACA,QACA,UACA,oBACA,YACA,WACA,qBACA,uBACA,mBACA,WACA,iBACA,kBACA,eACA,WACA,uDACI,yBACA,qBAIN,wDACI,gBACA,WAGJ,gHAEI,kBACA,UAOhB,2BACE,gBACA,YACA,gBACA,SAME,qBACI,aACA,gBAEA,mBAEA,uBACI,OACA,8CACA,eACA,kBACA,aACA,aACA,mBACA,uBACA,6BACA,0BAEA,8BACI,sBACA,sCACA,cAEA,mCACI,MAtIA,KA2IZ,0BACI,qBACA,gBAKR,4BACI,0BAKA,uCACI,aACA,iBAEA,8CACI,cAOhB,YACI,qBAEA,kBACI,eACA,eACA,kBACA,mBACA,kBAGJ,yBACI,WACA,qBACA,WACA,YACA,OACA,aACA,kBACA,kBACA,kBAEA,yBAGJ,iCACI,aAEJ,sDACI,YACA,eACA,cACA,kBAGJ,6BACI,eAMJ,8CACI,iBACA,sBAEJ,yCACI,qBAKR,eACI,oBACA,gBACA,kBACA,iBACA,sBAEA,iCACI,kBACA,kBACA,aAGJ,qBACI,qBACA,eACA,eACA,SACA,mBACA,cACA,qCAGJ,6CACI,gBACA,WAGJ,mCACI,WACA,gBAOR,eACI,kBACA,oBACA,oBACA,aACA,4BACA,6BACA,0BACA,sBACA,eACA,WACA,YACA,gBACA,iBACA,yBACA,sBACA,aAGJ,qBACI,kBACA,mBACA,WACA,OACA,yBACA,iBAGJ,4BACI,kBACA,OACA,MACA,WACA,YACA,kBACA,yCAGJ,uBACI,cACA,kBACA,gBAGJ,wBACI,oBACA,oBACA,aACA,yBACA,sBACA,8BACA,eAGJ,kCACI,aACA,mBACA,oBAGJ,mFAGI,eACA,qBAIA,6CACI,sBACA,kBACA,aACA,gBAEJ,6CACI,aACA,WACA,sBACA,cACA,iDACI,SAEJ,wDACI,aACA,WACA,YAGR,0CACI","file":"form-styles.css"}

View File

@@ -0,0 +1 @@
.form-group.has-errors{margin:0 -5px;padding:0 5px;border:1px solid rgba(255,0,0,.2);border-radius:3px;background:rgba(255,0,0,.05)}.form-errors{color:#b52b27}.form-honeybear{position:absolute!important;visibility:hidden;overflow:hidden;clip:rect(1px,1px,1px,1px);width:1px;height:1px}.form-errors p{margin:0}.form-input-file input{display:none}.form-input-file .dz-default.dz-message{position:absolute;top:50%;right:0;left:0;margin:0;-webkit-transform:translateY(-50%);transform:translateY(-50%);text-align:center}.form-input-file.dropzone{position:relative;min-height:70px;margin-bottom:.85rem;padding:.5rem;color:#aaa;border:2px dashed #ccc;border-radius:3px}.form-input-file.dropzone .dz-preview{margin:.5rem}.form-input-file.dropzone .dz-preview:hover{z-index:2}.form-input-file.dropzone .dz-preview .dz-error-message{width:auto;min-width:140px}.form-input-file.dropzone .dz-preview .dz-image,.form-input-file.dropzone .dz-preview.dz-file-preview .dz-image{z-index:1;border-radius:3px}.form-tabs .tabs-nav{display:flex;margin-bottom:-1px;padding-top:1px}.form-tabs .tabs-nav a{display:flex;padding:10px;cursor:pointer;transition:color .5s ease,background .5s ease;text-align:center;border-bottom:1px solid #eee;border-radius:5px 5px 0 0;flex:1;align-items:center;justify-content:center}.form-tabs .tabs-nav a.active{margin:0 -1px;border:1px solid #eee;border-bottom:1px solid transparent}.form-tabs .tabs-nav a.active span{color:#000}.form-tabs .tabs-nav span{line-height:1.1;display:inline-block}.form-tabs.subtle .tabs-nav{margin-right:0!important}.form-tabs .tabs-content .tab__content{display:none;padding-top:2rem}.form-tabs .tabs-content .tab__content.active{display:block}.checkboxes{display:inline-block}.checkboxes label{position:relative;display:inline;margin-right:15px;padding:0 0 0 20px;cursor:pointer}.checkboxes label:before{position:absolute;left:0;display:inline-block;width:20px;height:20px;margin-top:0;margin-right:10px;content:'';border:1px solid #e6e6e6;border-radius:3px}.checkboxes input[type=checkbox]{display:none}.checkboxes input[type=checkbox]:checked+label:before{font-size:20px;line-height:1;content:'\2713';text-align:center}.checkboxes.toggleable label{margin-right:0}.form-field-toggleable .checkboxes.toggleable{margin-right:5px;vertical-align:middle}.form-field-toggleable .checkboxes+label{display:inline-block}.switch-toggle{line-height:35px;display:inline-flex;overflow:hidden;border:1px solid #eee;border-radius:3px}.switch-toggle input[type=radio]{position:absolute;display:none;visibility:hidden}.switch-toggle label{display:inline-block;margin:0;padding:0 15px;cursor:pointer;transition:background-color .5s ease;white-space:nowrap;color:inherit}.switch-toggle input.highlight:checked+label{color:#fff;background:#333}.switch-toggle input:checked+label{color:#fff;background:#999}.signature-pad{font-size:10px;position:relative;display:flex;flex-direction:column;width:100%;max-width:700px;height:100%;max-height:460px;padding:16px;border:1px solid #f0f0f0;background-color:#fff}.signature-pad--body{position:relative;min-height:100px;border:1px solid #f6f6f6;flex:1}.signature-pad--body canvas{position:absolute;top:0;left:0;width:100%;height:100%;border-radius:4px;box-shadow:0 0 5px rgba(0,0,0,.02) inset}.signature-pad--footer{font-size:1.2em;text-align:center;color:#c3c3c3}.signature-pad--actions{display:flex;margin-top:8px;justify-content:space-between}[data-grav-field=array] .form-row{display:flex;margin-bottom:.5rem;align-items:center}[data-grav-field=array] .form-row>input,[data-grav-field=array] .form-row>textarea{display:inline-block;margin:0 .5rem}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,29 @@
if (typeof Object.assign !== 'function') {
// Must be writable: true, enumerable: false, configurable: true
Object.defineProperty(Object, 'assign', {
value: function assign(target, varArgs) { // .length of function is 2
'use strict';
if (target == null) { // TypeError if undefined or null
throw new TypeError('Cannot convert undefined or null to object');
}
var to = Object(target);
for (var index = 1; index < arguments.length; index++) {
var nextSource = arguments[index];
if (nextSource != null) { // Skip over if undefined or null
for (var nextKey in nextSource) {
// Avoid bugs when hasOwnProperty is shadowed
if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
to[nextKey] = nextSource[nextKey];
}
}
}
}
return to;
},
writable: true,
configurable: true
});
}

View File

@@ -0,0 +1,617 @@
/*!
* Signature Pad v2.3.2
* https://github.com/szimek/signature_pad
*
* Copyright 2017 Szymon Nowak
* Released under the MIT license
*
* The main idea and some parts of the code (e.g. drawing variable width Bézier curve) are taken from:
* http://corner.squareup.com/2012/07/smoother-signatures.html
*
* Implementation of interpolation using cubic Bézier curves is taken from:
* http://benknowscode.wordpress.com/2012/09/14/path-interpolation-using-cubic-bezier-and-control-point-estimation-in-javascript
*
* Algorithm for approximated length of a Bézier curve is taken from:
* http://www.lemoda.net/maths/bezier-length/index.html
*
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.SignaturePad = factory());
}(this, (function () { 'use strict';
function Point(x, y, time) {
this.x = x;
this.y = y;
this.time = time || new Date().getTime();
}
Point.prototype.velocityFrom = function (start) {
return this.time !== start.time ? this.distanceTo(start) / (this.time - start.time) : 1;
};
Point.prototype.distanceTo = function (start) {
return Math.sqrt(Math.pow(this.x - start.x, 2) + Math.pow(this.y - start.y, 2));
};
Point.prototype.equals = function (other) {
return this.x === other.x && this.y === other.y && this.time === other.time;
};
function Bezier(startPoint, control1, control2, endPoint) {
this.startPoint = startPoint;
this.control1 = control1;
this.control2 = control2;
this.endPoint = endPoint;
}
// Returns approximated length.
Bezier.prototype.length = function () {
var steps = 10;
var length = 0;
var px = void 0;
var py = void 0;
for (var i = 0; i <= steps; i += 1) {
var t = i / steps;
var cx = this._point(t, this.startPoint.x, this.control1.x, this.control2.x, this.endPoint.x);
var cy = this._point(t, this.startPoint.y, this.control1.y, this.control2.y, this.endPoint.y);
if (i > 0) {
var xdiff = cx - px;
var ydiff = cy - py;
length += Math.sqrt(xdiff * xdiff + ydiff * ydiff);
}
px = cx;
py = cy;
}
return length;
};
/* eslint-disable no-multi-spaces, space-in-parens */
Bezier.prototype._point = function (t, start, c1, c2, end) {
return start * (1.0 - t) * (1.0 - t) * (1.0 - t) + 3.0 * c1 * (1.0 - t) * (1.0 - t) * t + 3.0 * c2 * (1.0 - t) * t * t + end * t * t * t;
};
/* eslint-disable */
// http://stackoverflow.com/a/27078401/815507
function throttle(func, wait, options) {
var context, args, result;
var timeout = null;
var previous = 0;
if (!options) options = {};
var later = function later() {
previous = options.leading === false ? 0 : Date.now();
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
return function () {
var now = Date.now();
if (!previous && options.leading === false) previous = now;
var remaining = wait - (now - previous);
context = this;
args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
timeout = setTimeout(later, remaining);
}
return result;
};
}
function SignaturePad(canvas, options) {
var self = this;
var opts = options || {};
this.velocityFilterWeight = opts.velocityFilterWeight || 0.7;
this.minWidth = opts.minWidth || 0.5;
this.maxWidth = opts.maxWidth || 2.5;
this.throttle = 'throttle' in opts ? opts.throttle : 16; // in miliseconds
this.minDistance = 'minDistance' in opts ? opts.minDistance : 5;
if (this.throttle) {
this._strokeMoveUpdate = throttle(SignaturePad.prototype._strokeUpdate, this.throttle);
} else {
this._strokeMoveUpdate = SignaturePad.prototype._strokeUpdate;
}
this.dotSize = opts.dotSize || function () {
return (this.minWidth + this.maxWidth) / 2;
};
this.penColor = opts.penColor || 'black';
this.backgroundColor = opts.backgroundColor || 'rgba(0,0,0,0)';
this.onBegin = opts.onBegin;
this.onEnd = opts.onEnd;
this._canvas = canvas;
this._ctx = canvas.getContext('2d');
this.clear();
// We need add these inline so they are available to unbind while still having
// access to 'self' we could use _.bind but it's not worth adding a dependency.
this._handleMouseDown = function (event) {
if (event.which === 1) {
self._mouseButtonDown = true;
self._strokeBegin(event);
}
};
this._handleMouseMove = function (event) {
if (self._mouseButtonDown) {
self._strokeMoveUpdate(event);
}
};
this._handleMouseUp = function (event) {
if (event.which === 1 && self._mouseButtonDown) {
self._mouseButtonDown = false;
self._strokeEnd(event);
}
};
this._handleTouchStart = function (event) {
// Prevent scrolling.
event.preventDefault();
if (event.targetTouches.length === 1) {
var touch = event.changedTouches[0];
self._strokeBegin(touch);
}
};
this._handleTouchMove = function (event) {
// Prevent scrolling.
event.preventDefault();
var touch = event.targetTouches[0];
self._strokeMoveUpdate(touch);
};
this._handleTouchEnd = function (event) {
var wasCanvasTouched = event.target === self._canvas;
if (wasCanvasTouched) {
event.preventDefault();
self._strokeEnd(event);
}
};
// Enable mouse and touch event handlers
this.on();
}
// Public methods
SignaturePad.prototype.clear = function () {
var ctx = this._ctx;
var canvas = this._canvas;
ctx.fillStyle = this.backgroundColor;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillRect(0, 0, canvas.width, canvas.height);
this._data = [];
this._reset();
this._isEmpty = true;
};
SignaturePad.prototype.fromDataURL = function (dataUrl) {
var _this = this;
var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
var image = new Image();
var ratio = options.ratio || window.devicePixelRatio || 1;
var width = options.width || this._canvas.width / ratio;
var height = options.height || this._canvas.height / ratio;
this._reset();
image.src = dataUrl;
image.onload = function () {
_this._ctx.drawImage(image, 0, 0, width, height);
};
this._isEmpty = false;
};
SignaturePad.prototype.toDataURL = function (type) {
var _canvas;
switch (type) {
case 'image/svg+xml':
return this._toSVG();
default:
for (var _len = arguments.length, options = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
options[_key - 1] = arguments[_key];
}
return (_canvas = this._canvas).toDataURL.apply(_canvas, [type].concat(options));
}
};
SignaturePad.prototype.on = function () {
this._handleMouseEvents();
this._handleTouchEvents();
};
SignaturePad.prototype.off = function () {
// Pass touch events to canvas element on mobile IE11 and Edge.
this._canvas.style.msTouchAction = 'auto';
this._canvas.style.touchAction = 'auto';
this._canvas.removeEventListener('mousedown', this._handleMouseDown);
this._canvas.removeEventListener('mousemove', this._handleMouseMove);
document.removeEventListener('mouseup', this._handleMouseUp);
this._canvas.removeEventListener('touchstart', this._handleTouchStart);
this._canvas.removeEventListener('touchmove', this._handleTouchMove);
this._canvas.removeEventListener('touchend', this._handleTouchEnd);
};
SignaturePad.prototype.isEmpty = function () {
return this._isEmpty;
};
// Private methods
SignaturePad.prototype._strokeBegin = function (event) {
this._data.push([]);
this._reset();
this._strokeUpdate(event);
if (typeof this.onBegin === 'function') {
this.onBegin(event);
}
};
SignaturePad.prototype._strokeUpdate = function (event) {
var x = event.clientX;
var y = event.clientY;
var point = this._createPoint(x, y);
var lastPointGroup = this._data[this._data.length - 1];
var lastPoint = lastPointGroup && lastPointGroup[lastPointGroup.length - 1];
var isLastPointTooClose = lastPoint && point.distanceTo(lastPoint) < this.minDistance;
// Skip this point if it's too close to the previous one
if (!(lastPoint && isLastPointTooClose)) {
var _addPoint = this._addPoint(point),
curve = _addPoint.curve,
widths = _addPoint.widths;
if (curve && widths) {
this._drawCurve(curve, widths.start, widths.end);
}
this._data[this._data.length - 1].push({
x: point.x,
y: point.y,
time: point.time,
color: this.penColor
});
}
};
SignaturePad.prototype._strokeEnd = function (event) {
var canDrawCurve = this.points.length > 2;
var point = this.points[0]; // Point instance
if (!canDrawCurve && point) {
this._drawDot(point);
}
if (point) {
var lastPointGroup = this._data[this._data.length - 1];
var lastPoint = lastPointGroup[lastPointGroup.length - 1]; // plain object
// When drawing a dot, there's only one point in a group, so without this check
// such group would end up with exactly the same 2 points.
if (!point.equals(lastPoint)) {
lastPointGroup.push({
x: point.x,
y: point.y,
time: point.time,
color: this.penColor
});
}
}
if (typeof this.onEnd === 'function') {
this.onEnd(event);
}
};
SignaturePad.prototype._handleMouseEvents = function () {
this._mouseButtonDown = false;
this._canvas.addEventListener('mousedown', this._handleMouseDown);
this._canvas.addEventListener('mousemove', this._handleMouseMove);
document.addEventListener('mouseup', this._handleMouseUp);
};
SignaturePad.prototype._handleTouchEvents = function () {
// Pass touch events to canvas element on mobile IE11 and Edge.
this._canvas.style.msTouchAction = 'none';
this._canvas.style.touchAction = 'none';
this._canvas.addEventListener('touchstart', this._handleTouchStart);
this._canvas.addEventListener('touchmove', this._handleTouchMove);
this._canvas.addEventListener('touchend', this._handleTouchEnd);
};
SignaturePad.prototype._reset = function () {
this.points = [];
this._lastVelocity = 0;
this._lastWidth = (this.minWidth + this.maxWidth) / 2;
this._ctx.fillStyle = this.penColor;
};
SignaturePad.prototype._createPoint = function (x, y, time) {
var rect = this._canvas.getBoundingClientRect();
return new Point(x - rect.left, y - rect.top, time || new Date().getTime());
};
SignaturePad.prototype._addPoint = function (point) {
var points = this.points;
var tmp = void 0;
points.push(point);
if (points.length > 2) {
// To reduce the initial lag make it work with 3 points
// by copying the first point to the beginning.
if (points.length === 3) points.unshift(points[0]);
tmp = this._calculateCurveControlPoints(points[0], points[1], points[2]);
var c2 = tmp.c2;
tmp = this._calculateCurveControlPoints(points[1], points[2], points[3]);
var c3 = tmp.c1;
var curve = new Bezier(points[1], c2, c3, points[2]);
var widths = this._calculateCurveWidths(curve);
// Remove the first element from the list,
// so that we always have no more than 4 points in points array.
points.shift();
return { curve: curve, widths: widths };
}
return {};
};
SignaturePad.prototype._calculateCurveControlPoints = function (s1, s2, s3) {
var dx1 = s1.x - s2.x;
var dy1 = s1.y - s2.y;
var dx2 = s2.x - s3.x;
var dy2 = s2.y - s3.y;
var m1 = { x: (s1.x + s2.x) / 2.0, y: (s1.y + s2.y) / 2.0 };
var m2 = { x: (s2.x + s3.x) / 2.0, y: (s2.y + s3.y) / 2.0 };
var l1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
var l2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
var dxm = m1.x - m2.x;
var dym = m1.y - m2.y;
var k = l2 / (l1 + l2);
var cm = { x: m2.x + dxm * k, y: m2.y + dym * k };
var tx = s2.x - cm.x;
var ty = s2.y - cm.y;
return {
c1: new Point(m1.x + tx, m1.y + ty),
c2: new Point(m2.x + tx, m2.y + ty)
};
};
SignaturePad.prototype._calculateCurveWidths = function (curve) {
var startPoint = curve.startPoint;
var endPoint = curve.endPoint;
var widths = { start: null, end: null };
var velocity = this.velocityFilterWeight * endPoint.velocityFrom(startPoint) + (1 - this.velocityFilterWeight) * this._lastVelocity;
var newWidth = this._strokeWidth(velocity);
widths.start = this._lastWidth;
widths.end = newWidth;
this._lastVelocity = velocity;
this._lastWidth = newWidth;
return widths;
};
SignaturePad.prototype._strokeWidth = function (velocity) {
return Math.max(this.maxWidth / (velocity + 1), this.minWidth);
};
SignaturePad.prototype._drawPoint = function (x, y, size) {
var ctx = this._ctx;
ctx.moveTo(x, y);
ctx.arc(x, y, size, 0, 2 * Math.PI, false);
this._isEmpty = false;
};
SignaturePad.prototype._drawCurve = function (curve, startWidth, endWidth) {
var ctx = this._ctx;
var widthDelta = endWidth - startWidth;
var drawSteps = Math.floor(curve.length());
ctx.beginPath();
for (var i = 0; i < drawSteps; i += 1) {
// Calculate the Bezier (x, y) coordinate for this step.
var t = i / drawSteps;
var tt = t * t;
var ttt = tt * t;
var u = 1 - t;
var uu = u * u;
var uuu = uu * u;
var x = uuu * curve.startPoint.x;
x += 3 * uu * t * curve.control1.x;
x += 3 * u * tt * curve.control2.x;
x += ttt * curve.endPoint.x;
var y = uuu * curve.startPoint.y;
y += 3 * uu * t * curve.control1.y;
y += 3 * u * tt * curve.control2.y;
y += ttt * curve.endPoint.y;
var width = startWidth + ttt * widthDelta;
this._drawPoint(x, y, width);
}
ctx.closePath();
ctx.fill();
};
SignaturePad.prototype._drawDot = function (point) {
var ctx = this._ctx;
var width = typeof this.dotSize === 'function' ? this.dotSize() : this.dotSize;
ctx.beginPath();
this._drawPoint(point.x, point.y, width);
ctx.closePath();
ctx.fill();
};
SignaturePad.prototype._fromData = function (pointGroups, drawCurve, drawDot) {
for (var i = 0; i < pointGroups.length; i += 1) {
var group = pointGroups[i];
if (group.length > 1) {
for (var j = 0; j < group.length; j += 1) {
var rawPoint = group[j];
var point = new Point(rawPoint.x, rawPoint.y, rawPoint.time);
var color = rawPoint.color;
if (j === 0) {
// First point in a group. Nothing to draw yet.
// All points in the group have the same color, so it's enough to set
// penColor just at the beginning.
this.penColor = color;
this._reset();
this._addPoint(point);
} else if (j !== group.length - 1) {
// Middle point in a group.
var _addPoint2 = this._addPoint(point),
curve = _addPoint2.curve,
widths = _addPoint2.widths;
if (curve && widths) {
drawCurve(curve, widths, color);
}
} else {
// Last point in a group. Do nothing.
}
}
} else {
this._reset();
var _rawPoint = group[0];
drawDot(_rawPoint);
}
}
};
SignaturePad.prototype._toSVG = function () {
var _this2 = this;
var pointGroups = this._data;
var canvas = this._canvas;
var ratio = Math.max(window.devicePixelRatio || 1, 1);
var minX = 0;
var minY = 0;
var maxX = canvas.width / ratio;
var maxY = canvas.height / ratio;
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttributeNS(null, 'width', canvas.width);
svg.setAttributeNS(null, 'height', canvas.height);
this._fromData(pointGroups, function (curve, widths, color) {
var path = document.createElement('path');
// Need to check curve for NaN values, these pop up when drawing
// lines on the canvas that are not continuous. E.g. Sharp corners
// or stopping mid-stroke and than continuing without lifting mouse.
if (!isNaN(curve.control1.x) && !isNaN(curve.control1.y) && !isNaN(curve.control2.x) && !isNaN(curve.control2.y)) {
var attr = 'M ' + curve.startPoint.x.toFixed(3) + ',' + curve.startPoint.y.toFixed(3) + ' ' + ('C ' + curve.control1.x.toFixed(3) + ',' + curve.control1.y.toFixed(3) + ' ') + (curve.control2.x.toFixed(3) + ',' + curve.control2.y.toFixed(3) + ' ') + (curve.endPoint.x.toFixed(3) + ',' + curve.endPoint.y.toFixed(3));
path.setAttribute('d', attr);
path.setAttribute('stroke-width', (widths.end * 2.25).toFixed(3));
path.setAttribute('stroke', color);
path.setAttribute('fill', 'none');
path.setAttribute('stroke-linecap', 'round');
svg.appendChild(path);
}
}, function (rawPoint) {
var circle = document.createElement('circle');
var dotSize = typeof _this2.dotSize === 'function' ? _this2.dotSize() : _this2.dotSize;
circle.setAttribute('r', dotSize);
circle.setAttribute('cx', rawPoint.x);
circle.setAttribute('cy', rawPoint.y);
circle.setAttribute('fill', rawPoint.color);
svg.appendChild(circle);
});
var prefix = 'data:image/svg+xml;base64,';
var header = '<svg' + ' xmlns="http://www.w3.org/2000/svg"' + ' xmlns:xlink="http://www.w3.org/1999/xlink"' + (' viewBox="' + minX + ' ' + minY + ' ' + maxX + ' ' + maxY + '"') + (' width="' + maxX + '"') + (' height="' + maxY + '"') + '>';
var body = svg.innerHTML;
// IE hack for missing innerHTML property on SVGElement
if (body === undefined) {
var dummy = document.createElement('dummy');
var nodes = svg.childNodes;
dummy.innerHTML = '';
for (var i = 0; i < nodes.length; i += 1) {
dummy.appendChild(nodes[i].cloneNode(true));
}
body = dummy.innerHTML;
}
var footer = '</svg>';
var data = header + body + footer;
return prefix + btoa(data);
};
SignaturePad.prototype.fromData = function (pointGroups) {
var _this3 = this;
this.clear();
this._fromData(pointGroups, function (curve, widths) {
return _this3._drawCurve(curve, widths.start, widths.end);
}, function (rawPoint) {
return _this3._drawDot(rawPoint);
});
this._data = pointGroups;
};
SignaturePad.prototype.toData = function () {
return this._data;
};
return SignaturePad;
})));

View File

@@ -0,0 +1,461 @@
/**
* Grav Form XHR Submitter
*
* A modular system for handling form submissions via XMLHttpRequest (AJAX).
* Features include content replacement, captcha handling, and error management.
*/
(function() {
'use strict';
// Main namespace
window.GravFormXHR = {};
/**
* Core Module - Contains configuration and utility functions
*/
const Core = {
config: {
debug: false,
enableLoadingIndicator: false
},
/**
* Configure global settings
* @param {Object} options - Configuration options
*/
configure: function(options) {
Object.assign(this.config, options);
},
/**
* Logger utility
* @param {string} message - Message to log
* @param {string} level - Log level ('log', 'warn', 'error')
*/
log: function(message, level = 'log') {
if (!this.config.debug) return;
const validLevels = ['log', 'warn', 'error'];
const finalLevel = validLevels.includes(level) ? level : 'log';
console[finalLevel](`[GravFormXHR] ${message}`);
},
/**
* Display an error message within a target element
* @param {HTMLElement} target - The element to display the error in
* @param {string} message - The error message
*/
displayError: function(target, message) {
const errorMsgContainer = target.querySelector('.form-messages') || target;
const errorMsg = document.createElement('div');
errorMsg.className = 'form-message error';
errorMsg.textContent = message;
errorMsgContainer.insertBefore(errorMsg, errorMsgContainer.firstChild);
}
};
/**
* DOM Module - Handles DOM manipulation and form tracking
*/
const DOM = {
/**
* Find a form wrapper by formId
* @param {string} formId - ID of the form
* @returns {HTMLElement|null} - The wrapper element or null
*/
getFormWrapper: function(formId) {
const wrapperId = formId + '-wrapper';
return document.getElementById(wrapperId);
},
/**
* Add or remove loading indicators
* @param {HTMLElement} form - The form element
* @param {HTMLElement} wrapper - The wrapper element
* @param {boolean} isLoading - Whether to add or remove loading classes
*/
updateLoadingState: function(form, wrapper, isLoading) {
if (!Core.config.enableLoadingIndicator) return;
if (isLoading) {
wrapper.classList.add('loading');
form.classList.add('submitting');
} else {
wrapper.classList.remove('loading');
form.classList.remove('submitting');
}
},
/**
* Update form content with server response
* @param {string} responseText - Server response HTML
* @param {string} wrapperId - ID of the wrapper to update
* @param {string} formId - ID of the original form
*/
updateFormContent: function(responseText, wrapperId, formId) {
const wrapperElement = document.getElementById(wrapperId);
if (!wrapperElement) {
console.error(`Cannot update content: Wrapper #${wrapperId} not found`);
return;
}
Core.log(`Updating content for wrapper: ${wrapperId}`);
// Parse response
const tempDiv = document.createElement('div');
try {
tempDiv.innerHTML = responseText;
} catch (e) {
console.error(`Error parsing response HTML for wrapper: ${wrapperId}`, e);
Core.displayError(wrapperElement, 'An error occurred processing the server response.');
return;
}
try {
this._updateWrapperContent(tempDiv, wrapperElement, wrapperId, formId);
this._reinitializeUpdatedForm(wrapperElement, formId);
} catch (e) {
console.error(`Error during content update for wrapper ${wrapperId}:`, e);
Core.displayError(wrapperElement, 'An error occurred updating the form content.');
}
},
/**
* Update wrapper content based on response parsing strategy
* @private
*/
_updateWrapperContent: function(tempDiv, wrapperElement, wrapperId, formId) {
// Strategy 1: Look for matching wrapper ID in response
const newWrapperElement = tempDiv.querySelector('#' + wrapperId);
if (newWrapperElement) {
wrapperElement.innerHTML = newWrapperElement.innerHTML;
Core.log(`Update using newWrapperElement.innerHTML SUCCESSFUL for wrapper: ${wrapperId}`);
return;
}
// Strategy 2: Look for matching form ID in response
const hasMatchingForm = tempDiv.querySelector('#' + formId);
if (hasMatchingForm) {
Core.log(`Wrapper element #${wrapperId} not found in XHR response, but found matching form. Using entire response.`);
wrapperElement.innerHTML = tempDiv.innerHTML;
return;
}
// Strategy 3: Look for toast messages
const hasToastMessages = tempDiv.querySelector('.toast');
if (hasToastMessages) {
Core.log('Found toast messages in response. Updating wrapper with the response.');
wrapperElement.innerHTML = tempDiv.innerHTML;
return;
}
// Fallback: Use entire response with warning
Core.log('No matching content found in response. Response may not be valid for this wrapper.', 'warn');
wrapperElement.innerHTML = tempDiv.innerHTML;
},
/**
* Reinitialize updated form and its components
* @private
*/
_reinitializeUpdatedForm: function(wrapperElement, formId) {
const updatedForm = wrapperElement.querySelector('#' + formId);
if (updatedForm) {
Core.log(`Re-running initialization for form ${formId} after update`);
// First reinitialize any captchas
CaptchaManager.reinitializeAll(updatedForm);
// Trigger mutation._grav event for Dropzone and other field reinitializations
setTimeout(() => {
Core.log('Triggering mutation._grav event for field reinitialization');
// Trigger using jQuery if available (preferred method for compatibility)
if (typeof jQuery !== 'undefined') {
jQuery('body').trigger('mutation._grav', [wrapperElement]);
} else {
// Fallback: dispatch native custom event
const event = new CustomEvent('mutation._grav', {
detail: { target: wrapperElement },
bubbles: true
});
document.body.dispatchEvent(event);
}
}, 0);
// Then re-attach the XHR listener
setTimeout(() => {
FormHandler.setupListener(formId);
}, 10);
} else {
// Check if this was a successful submission with just a message
const hasSuccessMessage = wrapperElement.querySelector('.toast-success, .form-success');
if (hasSuccessMessage) {
Core.log('No form found after update, but success message detected. This appears to be a successful submission.');
} else {
console.warn(`Could not find form #${formId} inside the updated wrapper after update. Cannot re-attach listener/initializers.`);
}
}
}
};
/**
* XHR Module - Handles XMLHttpRequest operations
*/
const XHRManager = {
/**
* Send form data via XHR
* @param {HTMLFormElement} form - The form to submit
*/
sendFormData: function(form) {
const formId = form.id;
const wrapperId = formId + '-wrapper';
const wrapperElement = DOM.getFormWrapper(formId);
if (!wrapperElement) {
console.error(`XHR submission: Target wrapper element #${wrapperId} not found on the page! Cannot proceed.`);
form.innerHTML = '<p class="form-message error">Error: Form wrapper missing. Cannot update content.</p>';
return;
}
Core.log(`Initiating XHR submission for form: ${formId}, targeting wrapper: ${wrapperId}`);
DOM.updateLoadingState(form, wrapperElement, true);
const xhr = new XMLHttpRequest();
xhr.open(form.getAttribute('method') || 'POST', form.getAttribute('action') || window.location.href);
// Set Headers
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.setRequestHeader('X-Grav-Form-XHR', 'true');
// Success handler
xhr.onload = () => {
Core.log(`XHR request completed for form: ${formId}, Status: ${xhr.status}`);
DOM.updateLoadingState(form, wrapperElement, false);
if (xhr.status >= 200 && xhr.status < 300) {
DOM.updateFormContent(xhr.responseText, wrapperId, formId);
} else {
Core.log(`Form submission failed for form: ${formId}, HTTP Status: ${xhr.status} ${xhr.statusText}`, 'error');
Core.displayError(wrapperElement, `An error occurred during submission (Status: ${xhr.status}). Please check the form and try again.`);
}
};
// Network error handler
xhr.onerror = () => {
Core.log(`Form submission failed due to network error for form: ${formId}`, 'error');
DOM.updateLoadingState(form, wrapperElement, false);
Core.displayError(wrapperElement, 'A network error occurred. Please check your connection and try again.');
};
// Prepare and send data
try {
const formData = new FormData(form);
const urlEncodedData = new URLSearchParams(formData).toString();
Core.log(`Sending XHR request for form: ${formId} with custom header X-Grav-Form-XHR`);
xhr.send(urlEncodedData);
} catch (e) {
Core.log(`Error preparing or sending XHR request for form: ${formId}: ${e.message}`, 'error');
DOM.updateLoadingState(form, wrapperElement, false);
Core.displayError(wrapperElement, 'An unexpected error occurred before sending the form.');
}
}
};
/**
* CaptchaManager - Handles captcha registration and initialization
*/
const CaptchaManager = {
providers: {},
/**
* Register a captcha provider
* @param {string} name - Provider name
* @param {object} provider - Provider object with init and reset methods
*/
register: function(name, provider) {
this.providers[name] = provider;
Core.log(`Registered captcha provider: ${name}`);
},
/**
* Get a provider by name
* @param {string} name - Provider name
* @returns {object|null} Provider object or null if not found
*/
getProvider: function(name) {
return this.providers[name] || null;
},
/**
* Get all registered providers
* @returns {object} Object containing all providers
*/
getProviders: function() {
return this.providers;
},
/**
* Reinitialize all captchas in a form
* @param {HTMLFormElement} form - Form element containing captchas
*/
reinitializeAll: function(form) {
if (!form || !form.id) return;
const formId = form.id;
const containers = form.querySelectorAll('[data-captcha-provider]');
containers.forEach(container => {
const providerName = container.dataset.captchaProvider;
Core.log(`Found captcha container for provider: ${providerName} in form: ${formId}`);
const provider = this.getProvider(providerName);
if (provider && typeof provider.reset === 'function') {
setTimeout(() => {
try {
provider.reset(container, form);
Core.log(`Successfully reset ${providerName} captcha in form: ${formId}`);
} catch (e) {
console.error(`Error resetting ${providerName} captcha:`, e);
}
}, 0);
} else {
console.warn(`Could not reset captcha provider "${providerName}" - provider not registered or missing reset method`);
}
});
}
};
/**
* FormHandler - Handles form submission and event listeners
*/
const FormHandler = {
/**
* Submit a form via XHR
* @param {HTMLFormElement} form - Form to submit
*/
submitForm: function(form) {
if (!form || !form.id) {
console.error('submitForm called with invalid form element or form missing ID.');
return;
}
XHRManager.sendFormData(form);
},
/**
* Set up XHR submission listener for a form
* @param {string} formId - ID of the form
*/
setupListener: function(formId) {
setTimeout(() => {
const form = document.getElementById(formId);
if (!form) {
Core.log(`XHR Setup (delayed): Form with ID "${formId}" not found.`, 'warn');
return;
}
// Remove stale marker from previous runs
delete form.dataset.directXhrListenerAttached;
// Check if any captcha provider is handling the submission
const captchaContainer = form.querySelector('[data-captcha-provider][data-intercepts-submit="true"]');
if (!captchaContainer) {
// No intercepting captcha found, attach direct listener
this._attachDirectListener(form);
} else {
// Captcha will intercept, don't attach direct listener
const providerName = captchaContainer.dataset.captchaProvider;
Core.log(`XHR listener deferred: ${providerName} should intercept submit for form: ${formId}`);
// Ensure no stale listener marker remains
delete form.dataset.directXhrListenerAttached;
}
}, 0);
},
/**
* Attach a direct submit event listener to a form
* @private
* @param {HTMLFormElement} form - Form element
*/
_attachDirectListener: function(form) {
// Only proceed if XHR is enabled for this form
if (form.dataset.xhrEnabled !== 'true') {
Core.log(`XHR not enabled for form: ${form.id}. Skipping direct listener attachment.`);
return;
}
// Check if we already attached a listener
if (form.dataset.directXhrListenerAttached === 'true') {
Core.log(`Direct XHR listener already attached for form: ${form.id}`);
return;
}
const directXhrSubmitHandler = (event) => {
Core.log(`Direct XHR submit handler triggered for form: ${form.id}`);
event.preventDefault();
FormHandler.submitForm(form);
};
Core.log(`Attaching direct XHR listener for form: ${form.id}`);
form.addEventListener('submit', directXhrSubmitHandler);
form.dataset.directXhrListenerAttached = 'true';
}
};
// Initialize basic built-in captcha handlers
// Other providers will register themselves via separate handler JS files
const initializeBasicCaptchaHandlers = function() {
// Basic captcha handler (image refresh etc.)
CaptchaManager.register('basic-captcha', {
reset: function(container, form) {
const formId = form.id;
const captchaImg = container.querySelector('img');
const captchaInput = container.querySelector('input[type="text"]');
if (captchaImg) {
// Add a timestamp to force image reload
const timestamp = new Date().getTime();
const imgSrc = captchaImg.src.split('?')[0] + '?t=' + timestamp;
captchaImg.src = imgSrc;
// Clear any existing input
if (captchaInput) {
captchaInput.value = '';
}
Core.log(`Reset basic-captcha for form: ${formId}`);
}
}
});
};
// Initialize basic captcha handlers
initializeBasicCaptchaHandlers();
// --- Expose Public API ---
// Core configuration
window.GravFormXHR.configure = Core.configure.bind(Core);
// Form submission
window.GravFormXHR.submit = FormHandler.submitForm.bind(FormHandler);
window.GravFormXHR.setupListener = FormHandler.setupListener.bind(FormHandler);
// Captcha management
window.GravFormXHR.captcha = CaptchaManager;
// Legacy support
window.GravFormXHRSubmitters = {submit: FormHandler.submitForm.bind(FormHandler)};
window.attachFormSubmitListener = FormHandler.setupListener.bind(FormHandler);
})();

View File

@@ -0,0 +1,373 @@
name: Form
slug: form
type: plugin
version: 8.1.0
description: Enables forms handling and processing
icon: check-square
author:
name: Team Grav
email: devs@getgrav.org
url: https://getgrav.org
keywords: plugin, form
homepage: https://github.com/getgrav/grav-plugin-form
bugs: https://github.com/getgrav/grav-plugin-form/issues
license: MIT
dependencies:
- { name: grav, version: ">=1.7.49" }
form:
validation: strict
fields:
enabled:
type: hidden
label: PLUGIN_ADMIN.PLUGIN_STATUS
highlight: 1
default: 0
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
general:
type: section
title: PLUGIN_FORM.GENERAL
fields:
debug:
type: toggle
label: Debug
highlight: 1
default: 0
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
built_in_css:
type: toggle
label: PLUGIN_FORM.USE_BUILT_IN_CSS
highlight: 1
default: 1
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
inline_css:
type: toggle
label: PLUGIN_FORM.USE_INLINE_CSS
highlight: 1
default: 1
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
refresh_prevention:
type: toggle
label: PLUGIN_FORM.REFRESH_PREVENTION
help: PLUGIN_FORM.REFRESH_PREVENTION_HELP
highlight: 1
default: 0
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
client_side_validation:
type: toggle
label: PLUGIN_FORM.CLIENT_SIDE_VALIDATION
help: PLUGIN_FORM.CLIENT_SIDE_VALIDATION_HELP
highlight: 1
default: 1
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
inline_errors:
type: toggle
label: PLUGIN_FORM.INLINE_ERRORS
help: PLUGIN_FORM.INLINE_ERRORS_HELP
highlight: 0
default: 0
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
modular_form_fix:
type: toggle
label: PLUGIN_FORM.MODULAR_FORM_FIX
help: PLUGIN_FORM.MODULAR_FORM_FIX_HELP
highlight: 1
default: 1
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
files:
type: section
title: PLUGIN_FORM.FILES
fields:
files.multiple:
type: toggle
label: PLUGIN_FORM.ALLOW_MULTIPLE
help: PLUGIN_FORM.ALLOW_MULTIPLE_HELP
highlight: 1
default: 0
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
files.limit:
type: text
size: x-small
label: PLUGIN_FORM.LIMIT
help: PLUGIN_FORM.LIMIT_HELP
default: 10
validate:
type: number
min: 1
files.destination:
type: text
size: large
label: PLUGIN_FORM.DESTINATION
help: PLUGIN_FORM.DESTINATION_HELP
default: "@self"
files.accept:
type: selectize
size: large
label: PLUGIN_FORM.ACCEPT
help: PLUGIN_FORM.ACCEPT_HELP
classes: fancy
default:
- image/*
validate:
type: commalist
files.filesize:
type: text
label: PLUGIN_FORM.FILESIZE
help: PLUGIN_FORM.FILESIZE_HELP
size: x-small
default: 5
validate:
type: number
min: 0
files.avoid_overwriting:
type: toggle
label: PLUGIN_FORM.AVOID_OVERWRITING
help: PLUGIN_FORM.AVOID_OVERWRITING_HELP
highlight: 0
default: 0
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
files.random_name:
type: toggle
label: PLUGIN_FORM.RANDOM_NAME
help: PLUGIN_FORM.RANDOM_NAME_HELP
highlight: 0
default: 0
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
recaptcha:
type: section
title: PLUGIN_FORM.RECAPTCHA
fields:
recaptcha.version:
type: select
label: PLUGIN_FORM.RECAPTCHA_VERSION
default: 2-checkbox
options:
2-checkbox: PLUGIN_FORM.RECAPTCHA_VERSION_V2_CHECKBOX
2-invisible: PLUGIN_FORM.RECAPTCHA_VERSION_V2_INVISIBLE
3: PLUGIN_FORM.RECAPTCHA_VERSION_V3_LATEST
recaptcha.theme:
type: select
label: PLUGIN_FORM.RECAPTCHA_THEME
default: light
options:
light: PLUGIN_FORM.RECAPTCHA_THEME_LIGHT
dark: PLUGIN_FORM.RECAPTCHA_THEME_DARK
recaptcha.site_key:
recaptcha.site_key:
type: text
label: PLUGIN_FORM.RECAPTCHA_SITE_KEY
help: PLUGIN_FORM.RECAPTCHA_SITE_KEY_HELP
default: ""
recaptcha.secret_key:
type: text
label: PLUGIN_FORM.RECAPTCHA_SECRET_KEY
help: PLUGIN_FORM.RECAPTCHA_SECRET_KEY_HELP
default: ""
turnstile_captcha:
type: section
title: PLUGIN_FORM.TURNSTILE_CAPTCHA
fields:
turnstile.theme:
type: select
label: PLUGIN_FORM.RECAPTCHA_THEME
default: light
options:
light: PLUGIN_FORM.RECAPTCHA_THEME_LIGHT
dark: PLUGIN_FORM.RECAPTCHA_THEME_DARK
turnstile.site_key:
type: text
label: PLUGIN_FORM.RECAPTCHA_SITE_KEY
help: PLUGIN_FORM.RECAPTCHA_SITE_KEY_HELP
default: ""
turnstile.secret_key:
type: text
label: PLUGIN_FORM.RECAPTCHA_SECRET_KEY
help: PLUGIN_FORM.RECAPTCHA_SECRET_KEY_HELP
default: ""
basic_captcha:
type: section
title: PLUGIN_FORM.BASIC_CAPTCHA
fields:
basic_captcha.image.width:
type: number
label: PLUGIN_FORM.BASIC_CAPTCHA_BOX_WIDTH
default: 135
append: px
size: small
validate:
min: 100
max: 500
type: number
basic_captcha.image.height:
type: number
label: PLUGIN_FORM.BASIC_CAPTCHA_BOX_HEIGHT
default: 40
append: px
size: small
validate:
min: 30
max: 200
type: number
basic_captcha.chars.font:
type: select
label: PLUGIN_FORM.BASIC_CAPTCHA_FONT
default: zxx-noise.ttf
options:
"zxx-noise.ttf": zxx-Noise
"zxx-xed.ttf": zxx-Xed
"zxx-camo.ttf": zxx-Camo
"zxx-sans.ttf": zxx-Sans
basic_captcha.chars.size:
type: range
label: PLUGIN_FORM.BASIC_CAPTCHA_SIZE
default: 24
append: px
validate:
min: 12
max: 32
step: 2
basic_captcha.chars.bg:
type: colorpicker
size: small
label: PLUGIN_FORM.BASIC_CAPTCHA_BG_COLOR
default: "#ffffff"
basic_captcha.chars.text:
type: colorpicker
size: small
label: PLUGIN_FORM.BASIC_CAPTCHA_TEXT_COLOR
default: "#000000"
basic_captcha.chars.start_x:
type: number
label: PLUGIN_FORM.BASIC_CAPTCHA_START_X
default: 5
append: px
size: small
validate:
min: 0
type: number
basic_captcha.chars.start_y:
type: number
label: PLUGIN_FORM.BASIC_CAPTCHA_START_Y
default: 30
append: px
size: small
validate:
min: 0
type: number
basic_captcha.type:
type: elements
label: PLUGIN_FORM.BASIC_CAPTCHA_TYPE
default: "characters"
size: medium
options:
characters: Random Characters
math: Math Puzzle
fields:
characters:
type: element
fields:
basic_captcha.chars.length:
type: range
label: PLUGIN_FORM.BASIC_CAPTCHA_LENGTH
default: 6
validate:
min: 4
max: 12
append: characters
math:
type: element
fields:
basic_captcha.math.min:
type: number
label: PLUGIN_FORM.BASIC_CAPTCHA_MATH_MIN
default: 1
size: small
validate:
min: 0
type: number
basic_captcha.math.max:
type: number
label: PLUGIN_FORM.BASIC_CAPTCHA_MATH_MAX
default: 10
size: small
validate:
min: 1
type: number
basic_captcha.math.operators:
type: selectize
selectize:
options:
- value: "+"
text: "+ Addition"
- value: "-"
text: "- Subtraction"
- value: "*"
text: "x Multiplication"
- value: "/"
text: "/ Division"
label: PLUGIN_FORM.BASIC_CAPTCHA_MATH_OPERATORS
validate:
type: commalist

View File

@@ -0,0 +1,596 @@
<?php
namespace Grav\Plugin\Form\Captcha;
use Grav\Common\Grav;
class BasicCaptcha
{
protected $session = null;
protected $key = 'basic_captcha_value';
protected $typeKey = 'basic_captcha_type';
protected $config = null;
public function __construct($fieldConfig = null)
{
$this->session = Grav::instance()['session'];
// Load global configuration
$globalConfig = Grav::instance()['config']->get('plugins.form.basic_captcha', []);
// Merge field-specific config with global config
if ($fieldConfig && is_array($fieldConfig)) {
$this->config = array_replace_recursive($globalConfig, $fieldConfig);
} else {
$this->config = $globalConfig;
}
}
public function getCaptchaCode($length = null): string
{
// Support both 'type' (from global config) and 'captcha_type' (from field config)
$type = $this->config['captcha_type'] ?? $this->config['type'] ?? 'characters';
// Store the captcha type in session for validation
$this->setSession($this->typeKey, $type);
switch ($type) {
case 'dotcount':
return $this->getDotCountCaptcha($this->config);
case 'position':
return $this->getPositionCaptcha($this->config);
case 'math':
return $this->getMathCaptcha($this->config);
case 'characters':
default:
return $this->getCharactersCaptcha($this->config, $length);
}
}
/**
* Creates a dot counting captcha - user has to count dots of a specific color
*/
protected function getDotCountCaptcha($config): string
{
// Define colors with names
$colors = [
'red' => [255, 0, 0],
'blue' => [0, 0, 255],
'green' => [0, 128, 0],
'yellow' => [255, 255, 0],
'purple' => [128, 0, 128],
'orange' => [255, 165, 0]
];
// Pick a random color to count
$colorNames = array_keys($colors);
$targetColorName = $colorNames[array_rand($colorNames)];
$targetColor = $colors[$targetColorName];
// Generate a random number of dots for the target color (between 5-10)
$targetCount = mt_rand(5, 10);
// Store the expected answer
$this->setSession($this->key, (string) $targetCount);
// Return description text
return "count_dots|{$targetColorName}|".implode(',', $targetColor);
}
/**
* Creates a position-based captcha - user has to identify position of a symbol
*/
protected function getPositionCaptcha($config): string
{
// Define possible symbols - using simple ASCII characters
$symbols = ['*', '+', '$', '#', '@', '!', '?', '%', '&', '='];
// Define positions - simpler options
$positions = ['top', 'bottom', 'left', 'right', 'center'];
// Pick a random symbol and position
$targetSymbol = $symbols[array_rand($symbols)];
$targetPosition = $positions[array_rand($positions)];
// Store the expected answer
$this->setSession($this->key, $targetPosition);
// Return the instruction and symbol
return "position|{$targetSymbol}|{$targetPosition}";
}
/**
* Creates a math-based captcha
*/
protected function getMathCaptcha($config): string
{
$min = $config['math']['min'] ?? 1;
$max = $config['math']['max'] ?? 12;
$operators = $config['math']['operators'] ?? ['+', '-', '*'];
$first_num = random_int($min, $max);
$second_num = random_int($min, $max);
$operator = $operators[array_rand($operators)];
// Calculator
if ($operator === '-') {
if ($first_num < $second_num) {
$result = "$second_num - $first_num";
$captcha_code = $second_num - $first_num;
} else {
$result = "$first_num - $second_num";
$captcha_code = $first_num - $second_num;
}
} elseif ($operator === '*') {
$result = "{$first_num} x {$second_num}";
$captcha_code = $first_num * $second_num;
} elseif ($operator === '+') {
$result = "$first_num + $second_num";
$captcha_code = $first_num + $second_num;
}
$this->setSession($this->key, (string) $captcha_code);
return $result;
}
/**
* Creates a character-based captcha
*/
protected function getCharactersCaptcha($config, $length = null): string
{
if ($length === null) {
$length = $config['chars']['length'] ?? 6;
}
// Use more complex character set with mixed case and exclude similar-looking characters
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
$captcha_code = '';
// Generate random characters
for ($i = 0; $i < $length; $i++) {
$captcha_code .= $chars[random_int(0, strlen($chars) - 1)];
}
$this->setSession($this->key, $captcha_code);
return $captcha_code;
}
public function setSession($key, $value): void
{
$this->session->$key = $value;
}
public function getSession($key = null): ?string
{
if ($key === null) {
$key = $this->key;
}
return $this->session->$key ?? null;
}
/**
* Create captcha image based on the type
*/
public function createCaptchaImage($captcha_code)
{
// Determine image dimensions based on type
$isCharacterCaptcha = false;
if (strpos($captcha_code, '|') === false && !preg_match('/[\+\-x]/', $captcha_code)) {
$isCharacterCaptcha = true;
}
// Use box_width/box_height for character captchas if specified, otherwise use default image dimensions
if ($isCharacterCaptcha && isset($this->config['chars']['box_width'])) {
$width = $this->config['chars']['box_width'];
} else {
$width = $this->config['image']['width'] ?? 135;
}
if ($isCharacterCaptcha && isset($this->config['chars']['box_height'])) {
$height = $this->config['chars']['box_height'];
} else {
$height = $this->config['image']['height'] ?? 40;
}
// Create a blank image
$image = imagecreatetruecolor($width, $height);
// Set background color (support both image.bg and chars.bg for character captchas)
$bgColor = '#ffffff';
if ($isCharacterCaptcha && isset($this->config['chars']['bg'])) {
$bgColor = $this->config['chars']['bg'];
} elseif (isset($this->config['image']['bg'])) {
$bgColor = $this->config['image']['bg'];
}
$bg = $this->hexToRgb($bgColor);
$backgroundColor = imagecolorallocate($image, $bg[0], $bg[1], $bg[2]);
imagefill($image, 0, 0, $backgroundColor);
// Parse the captcha code to determine type
if (strpos($captcha_code, '|') !== false) {
$parts = explode('|', $captcha_code);
$type = $parts[0];
switch ($type) {
case 'count_dots':
return $this->createDotCountImage($image, $parts, $this->config);
case 'position':
return $this->createPositionImage($image, $parts, $this->config);
}
} else {
// Assume it's a character or math captcha if no type indicator
if (preg_match('/[\+\-x]/', $captcha_code)) {
return $this->createMathImage($image, $captcha_code, $this->config);
} else {
return $this->createCharacterImage($image, $captcha_code, $this->config);
}
}
return $image;
}
/**
* Create image for dot counting captcha
*/
protected function createDotCountImage($image, $parts, $config)
{
$colorName = $parts[1];
$targetColorRGB = explode(',', $parts[2]);
$width = imagesx($image);
$height = imagesy($image);
// Allocate target color
$targetColor = imagecolorallocate($image, $targetColorRGB[0], $targetColorRGB[1], $targetColorRGB[2]);
// Create other distraction colors
$distractionColors = [];
$colorOptions = [
[255, 0, 0], // red
[0, 0, 255], // blue
[0, 128, 0], // green
[255, 255, 0], // yellow
[128, 0, 128], // purple
[255, 165, 0] // orange
];
foreach ($colorOptions as $rgb) {
if ($rgb[0] != $targetColorRGB[0] || $rgb[1] != $targetColorRGB[1] || $rgb[2] != $targetColorRGB[2]) {
$distractionColors[] = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
}
}
// Get target count from session
$targetCount = (int) $this->getSession();
// Draw instruction text
$fontPath = __DIR__.'/../../fonts/'.($config['chars']['font'] ?? 'zxx-xed.ttf');
$black = imagecolorallocate($image, 0, 0, 0);
imagettftext($image, 10, 0, 5, 15, $black, $fontPath, "Count {$colorName}:");
// Simplified approach to prevent overlapping
// Divide the image into a grid and place one dot per cell
$gridCells = [];
$gridRows = 2;
$gridCols = 4;
// Build available grid cells
for ($y = 0; $y < $gridRows; $y++) {
for ($x = 0; $x < $gridCols; $x++) {
$gridCells[] = [$x, $y];
}
}
// Shuffle grid cells for random placement
shuffle($gridCells);
// Calculate cell dimensions
$cellWidth = ($width - 20) / $gridCols;
$cellHeight = ($height - 20) / $gridRows;
// Dot size for better visibility
$dotSize = 8;
// Draw target dots first (taking the first N cells)
for ($i = 0; $i < $targetCount && $i < count($gridCells); $i++) {
$cell = $gridCells[$i];
$gridX = $cell[0];
$gridY = $cell[1];
// Calculate center position of cell with small random offset
$x = 10 + ($gridX + 0.5) * $cellWidth + mt_rand(-2, 2);
$y = 20 + ($gridY + 0.5) * $cellHeight + mt_rand(-2, 2);
// Draw the dot
imagefilledellipse($image, $x, $y, $dotSize, $dotSize, $targetColor);
// Add a small border for better contrast
imageellipse($image, $x, $y, $dotSize + 2, $dotSize + 2, $black);
}
// Draw distraction dots using remaining grid cells
$distractionCount = min(mt_rand(8, 15), count($gridCells) - $targetCount);
for ($i = 0; $i < $distractionCount; $i++) {
// Get the next available cell
$cellIndex = $targetCount + $i;
if ($cellIndex >= count($gridCells)) {
break; // No more cells available
}
$cell = $gridCells[$cellIndex];
$gridX = $cell[0];
$gridY = $cell[1];
// Calculate center position of cell with small random offset
$x = 10 + ($gridX + 0.5) * $cellWidth + mt_rand(-2, 2);
$y = 20 + ($gridY + 0.5) * $cellHeight + mt_rand(-2, 2);
// Draw the dot with a random distraction color
$color = $distractionColors[array_rand($distractionColors)];
imagefilledellipse($image, $x, $y, $dotSize, $dotSize, $color);
}
// Add subtle grid lines to help with counting
$lightGray = imagecolorallocate($image, 230, 230, 230);
for ($i = 1; $i < $gridCols; $i++) {
imageline($image, 10 + $i * $cellWidth, 20, 10 + $i * $cellWidth, $height - 5, $lightGray);
}
for ($i = 1; $i < $gridRows; $i++) {
imageline($image, 10, 20 + $i * $cellHeight, $width - 10, 20 + $i * $cellHeight, $lightGray);
}
// Add minimal noise
$this->addImageNoise($image, 15);
return $image;
}
/**
* Create image for position captcha
*/
protected function createPositionImage($image, $parts, $config)
{
$symbol = $parts[1];
$position = $parts[2];
$width = imagesx($image);
$height = imagesy($image);
// Allocate colors
$black = imagecolorallocate($image, 0, 0, 0);
$red = imagecolorallocate($image, 255, 0, 0);
// Draw instruction text
$fontPath = __DIR__.'/../../fonts/'.($config['chars']['font'] ?? 'zxx-xed.ttf');
imagettftext($image, 9, 0, 5, 15, $black, $fontPath, "Position of symbol?");
// Determine symbol position based on the target position
$symbolX = $width / 2;
$symbolY = $height / 2;
switch ($position) {
case 'top':
$symbolX = $width / 2;
$symbolY = 20;
break;
case 'bottom':
$symbolX = $width / 2;
$symbolY = $height - 10;
break;
case 'left':
$symbolX = 20;
$symbolY = $height / 2;
break;
case 'right':
$symbolX = $width - 20;
$symbolY = $height / 2;
break;
case 'center':
$symbolX = $width / 2;
$symbolY = $height / 2;
break;
}
// Draw the symbol - make it larger and in red for visibility
imagettftext($image, 20, 0, $symbolX - 8, $symbolY + 8, $red, $fontPath, $symbol);
// Draw a grid to make positions clearer
$gray = imagecolorallocate($image, 200, 200, 200);
imageline($image, $width / 2, 15, $width / 2, $height - 5, $gray);
imageline($image, 5, $height / 2, $width - 5, $height / 2, $gray);
// Add minimal noise
$this->addImageNoise($image, 10);
return $image;
}
/**
* Create image for math captcha
*/
protected function createMathImage($image, $mathExpression, $config)
{
$width = imagesx($image);
$height = imagesy($image);
// Get font and colors
$fontPath = __DIR__.'/../../fonts/'.($config['chars']['font'] ?? 'zxx-xed.ttf');
$textColor = imagecolorallocate($image, 0, 0, 0);
// Draw the math expression
$fontSize = 16;
$textBox = imagettfbbox($fontSize, 0, $fontPath, $mathExpression);
$textWidth = $textBox[2] - $textBox[0];
$textHeight = $textBox[1] - $textBox[7];
$textX = ($width - $textWidth) / 2;
$textY = ($height + $textHeight) / 2;
imagettftext($image, $fontSize, 0, $textX, $textY, $textColor, $fontPath, $mathExpression);
// Add visual noise and distortions to prevent OCR
$this->addImageNoise($image, 25);
$this->addWaveDistortion($image);
return $image;
}
/**
* Create image for character captcha
*/
protected function createCharacterImage($image, $captcha_code, $config)
{
$width = imagesx($image);
$height = imagesy($image);
// Get font settings with support for custom box dimensions, position, and colors
$fontPath = __DIR__.'/../../fonts/'.($config['chars']['font'] ?? 'zxx-xed.ttf');
$fontSize = $config['chars']['size'] ?? 16;
// Support custom text color (defaults to black)
$textColorHex = $config['chars']['text'] ?? '#000000';
$textRgb = $this->hexToRgb($textColorHex);
$textColor = imagecolorallocate($image, $textRgb[0], $textRgb[1], $textRgb[2]);
// Support custom start position (useful for fine-tuning text placement)
$startX = $config['chars']['start_x'] ?? ($width / (strlen($captcha_code) + 2));
$baseY = $config['chars']['start_y'] ?? ($height / 2 + 5);
// Draw each character with random rotation and position
$charWidth = $width / (strlen($captcha_code) + 2);
for ($i = 0; $i < strlen($captcha_code); $i++) {
$char = $captcha_code[$i];
$angle = mt_rand(-15, 15); // Random rotation
// Random vertical position with custom base Y
$y = $baseY + mt_rand(-5, 5);
imagettftext($image, $fontSize, $angle, $startX, $y, $textColor, $fontPath, $char);
// Move to next character position with some randomness
$startX += $charWidth + mt_rand(-5, 5);
}
// Add visual noise and distortions
$this->addImageNoise($image, 25);
$this->addWaveDistortion($image);
return $image;
}
/**
* Add random noise to the image
*/
protected function addImageNoise($image, $density = 100)
{
$width = imagesx($image);
$height = imagesy($image);
// For performance, reduce density
$density = min($density, 30);
// Add random dots
for ($i = 0; $i < $density; $i++) {
$x = mt_rand(0, $width - 1);
$y = mt_rand(0, $height - 1);
$shade = mt_rand(150, 200);
$color = imagecolorallocate($image, $shade, $shade, $shade);
imagesetpixel($image, $x, $y, $color);
}
// Add a few random lines
$lineCount = min(3, mt_rand(2, 3));
for ($i = 0; $i < $lineCount; $i++) {
$x1 = mt_rand(0, $width / 4);
$y1 = mt_rand(0, $height - 1);
$x2 = mt_rand(3 * $width / 4, $width - 1);
$y2 = mt_rand(0, $height - 1);
$shade = mt_rand(150, 200);
$color = imagecolorallocate($image, $shade, $shade, $shade);
imageline($image, $x1, $y1, $x2, $y2, $color);
}
}
/**
* Add wave distortion to the image
*/
protected function addWaveDistortion($image)
{
$width = imagesx($image);
$height = imagesy($image);
// Create temporary image
$temp = imagecreatetruecolor($width, $height);
$bg = imagecolorallocate($temp, 255, 255, 255);
imagefill($temp, 0, 0, $bg);
// Copy original to temp
imagecopy($temp, $image, 0, 0, 0, 0, $width, $height);
// Clear original image
$bg = imagecolorallocate($image, 255, 255, 255);
imagefill($image, 0, 0, $bg);
// Apply simplified wave distortion
$amplitude = mt_rand(1, 2);
$period = mt_rand(10, 15);
// Process only every 2nd pixel for better performance
for ($x = 0; $x < $width; $x += 2) {
$wave = sin($x / $period) * $amplitude;
for ($y = 0; $y < $height; $y += 2) {
$yp = $y + $wave;
if ($yp >= 0 && $yp < $height) {
$color = imagecolorat($temp, $x, $yp);
imagesetpixel($image, $x, $y, $color);
// Fill adjacent pixel for better performance
if ($x + 1 < $width && $y + 1 < $height) {
imagesetpixel($image, $x + 1, $y, $color);
}
}
}
}
imagedestroy($temp);
}
public function renderCaptchaImage($imageData): void
{
header("Content-type: image/jpeg");
imagejpeg($imageData);
}
public function validateCaptcha($formData): bool
{
$isValid = false;
$capchaSessionData = $this->getSession();
// Make validation case-insensitive
if (strtolower((string) $capchaSessionData) == strtolower((string) $formData)) {
$isValid = true;
}
// Debug validation if enabled
$grav = Grav::instance();
if ($grav['config']->get('plugins.form.basic_captcha.debug', false)) {
$grav['log']->debug("Captcha Validation - Expected: '{$capchaSessionData}', Got: '{$formData}', Result: ".
($isValid ? 'valid' : 'invalid'));
}
// Regenerate a new captcha after validation
$this->setSession($this->key, null);
return $isValid;
}
private function hexToRgb($hex): array
{
return sscanf($hex, "#%02x%02x%02x");
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace Grav\Plugin\Form\Captcha;
use Grav\Common\Grav;
/**
* Basic Captcha provider implementation
*/
class BasicCaptchaProvider implements CaptchaProviderInterface
{
/** @var array */
protected $config;
public function __construct()
{
$this->config = Grav::instance()['config']->get('plugins.form.basic_captcha', []);
}
/**
* {@inheritdoc}
*/
public function validate(array $form, array $params = []): array
{
$grav = Grav::instance();
$session = $grav['session'];
try {
// Get the expected answer from session
// Make sure to use the same session key that the image generation code uses
$expectedValue = $session->basic_captcha_value ?? null; // Changed from basic_captcha to basic_captcha_value
// Get the captcha type from session (stored during generation)
$captchaType = $session->basic_captcha_type ?? null;
// Get the user's answer
$userValue = $form['basic-captcha'] ?? null;
if (!$expectedValue) {
return [
'success' => false,
'error' => 'missing-session-data',
'details' => ['error' => 'No captcha value found in session']
];
}
if (!$userValue) {
return [
'success' => false,
'error' => 'missing-input-response',
'details' => ['error' => 'User did not enter a captcha value']
];
}
// Compare the values based on the type stored in session
// If type is not in session, try to infer from global/field config
if (!$captchaType) {
$captchaType = $this->config['captcha_type'] ?? $this->config['type'] ?? 'characters';
}
if ($captchaType === 'characters') {
$isValid = strtolower((string)$userValue) === strtolower((string)$expectedValue);
} else {
// For math, dotcount, position - ensure both are treated as integers or exact match
$isValid = (int)$userValue === (int)$expectedValue;
}
if (!$isValid) {
return [
'success' => false,
'error' => 'validation-failed',
'details' => [
'expected' => $expectedValue,
'received' => $userValue
]
];
}
// Clear the session values to prevent reuse
$session->basic_captcha_value = null;
$session->basic_captcha_type = null;
return [
'success' => true
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage(),
'details' => ['exception' => get_class($e)]
];
}
}
/**
* {@inheritdoc}
*/
public function getClientProperties(string $formId, array $field): array
{
$grav = Grav::instance();
$session = $grav['session'];
// Merge field-level configuration with global defaults
$fieldConfig = array_replace_recursive($this->config, $field);
// Remove non-config keys from field array
unset($fieldConfig['type'], $fieldConfig['label'], $fieldConfig['placeholder'],
$fieldConfig['validate'], $fieldConfig['name'], $fieldConfig['classes']);
// Generate unique field ID for this form/field combination
$fieldId = md5($formId . '_basic_captcha_' . ($field['name'] ?? 'default'));
// Store field configuration in session for image generation
$session->{"basic_captcha_config_{$fieldId}"} = $fieldConfig;
$captchaType = $fieldConfig['type'] ?? 'math';
return [
'provider' => 'basic-captcha',
'type' => $captchaType,
'imageUrl' => "/forms-basic-captcha-image.jpg?field={$fieldId}",
'refreshable' => true,
'containerId' => "basic-captcha-{$formId}",
'fieldId' => $fieldId
];
}
/**
* {@inheritdoc}
*/
public function getTemplateName(): string
{
return 'forms/fields/basic-captcha/basic-captcha.html.twig';
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Grav\Plugin\Form\Captcha;
use Grav\Common\Grav;
/**
* Factory for captcha providers
*/
class CaptchaFactory
{
/** @var array */
protected static $providers = [];
/**
* Register a captcha provider
*
* @param string $name Provider name
* @param string|CaptchaProviderInterface $provider Provider class or instance
* @return void
*/
public static function registerProvider(string $name, $provider): void
{
// If it's a class name, instantiate it
if (is_string($provider) && class_exists($provider)) {
$provider = new $provider();
}
if (!$provider instanceof CaptchaProviderInterface) {
Grav::instance()['log']->error("Cannot register captcha provider '{$name}': Provider must implement CaptchaProviderInterface");
return;
}
self::$providers[$name] = $provider;
// Grav::instance()['log']->debug("Registered captcha provider: {$name}");
}
/**
* Check if a provider is registered
*
* @param string $name Provider name
* @return bool
*/
public static function hasProvider(string $name): bool
{
return isset(self::$providers[$name]);
}
/**
* Get a provider by name
*
* @param string $name Provider name
* @return CaptchaProviderInterface|null Provider instance or null if not found
*/
public static function getProvider(string $name): ?CaptchaProviderInterface
{
return self::$providers[$name] ?? null;
}
/**
* Get all registered providers
*
* @return array
*/
public static function getProviders(): array
{
return self::$providers;
}
/**
* Register all default captcha providers
*
* @return void
*/
public static function registerDefaultProviders(): void
{
// Register built-in providers
self::registerProvider('recaptcha', new ReCaptchaProvider());
self::registerProvider('turnstile', new TurnstileProvider());
self::registerProvider('basic-captcha', new BasicCaptchaProvider());
// Log the registration
// Grav::instance()['log']->debug('Registered default captcha providers');
}
}

View File

@@ -0,0 +1,244 @@
<?php
namespace Grav\Plugin\Form\Captcha;
use Grav\Common\Grav;
use Grav\Plugin\Form\Form;
use RocketTheme\Toolbox\Event\Event;
/**
* Central manager for captcha processing
*/
class CaptchaManager
{
/**
* Initialize the captcha manager
*
* @return void
*/
public static function initialize(): void
{
// Register all default captcha providers
CaptchaFactory::registerDefaultProviders();
// Allow plugins to register custom captcha providers
Grav::instance()->fireEvent('onFormRegisterCaptchaProviders');
}
/**
* Process a captcha validation
*
* @param Form $form The form to validate
* @param array|null $params Optional parameters
* @return bool True if validation succeeded
*/
public static function validateCaptcha(Form $form, $params = null): bool
{
// Handle case where $params is a boolean (backward compatibility)
if (!is_array($params)) {
$params = [];
}
// --- 1. Find the captcha field in the form ---
$captchaField = null;
$providerName = null;
$formFields = $form->value()->blueprints()->get('form/fields');
foreach ($formFields as $fieldName => $fieldDef) {
$fieldType = $fieldDef['type'] ?? null;
// Check for modern captcha type with provider
if ($fieldType === 'captcha') {
$captchaField = $fieldDef;
$providerName = $fieldDef['provider'] ?? 'recaptcha';
break;
}
// Check for legacy type-based providers (like basic-captcha and turnstile)
// This is for backward compatibility
elseif ($fieldType && CaptchaFactory::hasProvider($fieldType)) {
$captchaField = $fieldDef;
$providerName = $fieldType;
break;
}
}
if (!$captchaField || !$providerName) {
// No captcha field found or no provider specified
return true;
}
// --- 2. Get provider and validate ---
$provider = CaptchaFactory::getProvider($providerName);
if (!$provider) {
Grav::instance()['log']->error("Form Captcha: Unknown provider '{$providerName}' requested");
return false;
}
// Allow plugins to modify the validation parameters
$validationEvent = new Event([
'form' => $form,
'field' => $captchaField,
'provider' => $providerName,
'params' => $params
]);
Grav::instance()->fireEvent('onBeforeCaptchaValidation', $validationEvent);
$params = $validationEvent['params'];
// Validate using the provider
try {
$result = $provider->validate($form->value()->toArray(), $params);
if (!$result['success']) {
$logDetails = $result['details'] ?? [];
$errorMessage = self::getErrorMessage($captchaField, $result['error'] ?? 'validation-failed', $providerName);
// Fire validation error event
Grav::instance()->fireEvent('onFormValidationError', new Event([
'form' => $form,
'message' => $errorMessage,
'provider' => $providerName
]));
// Log the failure
$uri = Grav::instance()['uri'];
Grav::instance()['log']->warning(
"Form Captcha ({$providerName}) validation failed: [{$uri->route()}] Details: " .
json_encode($logDetails)
);
return false;
}
// Log success
Grav::instance()['log']->info("Form Captcha ({$providerName}) validation successful for form: " . $form->name);
// Fire success event
Grav::instance()->fireEvent('onCaptchaValidationSuccess', new Event([
'form' => $form,
'provider' => $providerName
]));
return true;
} catch (\Exception $e) {
// Handle other errors
Grav::instance()['log']->error("Form Captcha ({$providerName}) validation error: " . $e->getMessage());
$errorMessage = Grav::instance()['language']->translate('PLUGIN_FORM.ERROR_VALIDATING_CAPTCHA');
Grav::instance()->fireEvent('onFormValidationError', new Event([
'form' => $form,
'message' => $errorMessage,
'provider' => $providerName,
'exception' => $e
]));
return false;
}
}
/**
* Get appropriate error message based on error code and field definition
*
* @param array $field Field definition
* @param string $errorCode Error code
* @param string $provider Provider name
* @return string
*/
protected static function getErrorMessage(array $field, string $errorCode, string $provider): string
{
$grav = Grav::instance();
// First check for specific message in field definition
if (isset($field['captcha_not_validated'])) {
return $field['captcha_not_validated'];
}
// Then check for specific error code message
if ($errorCode === 'missing-input-response') {
return $grav['language']->translate('PLUGIN_FORM.ERROR_CAPTCHA_NOT_COMPLETED');
}
// Allow providers to supply custom error messages via event
$messageEvent = new Event([
'provider' => $provider,
'errorCode' => $errorCode,
'field' => $field,
'message' => null
]);
$grav->fireEvent('onCaptchaErrorMessage', $messageEvent);
if ($messageEvent['message']) {
return $messageEvent['message'];
}
// Finally fall back to generic message
return $grav['language']->translate('PLUGIN_FORM.ERROR_VALIDATING_CAPTCHA');
}
/**
* Get client-side initialization data for a captcha field
*
* @param string $formId Form ID
* @param array $field Field definition
* @return array Client properties
*/
public static function getClientProperties(string $formId, array $field): array
{
$providerName = $field['provider'] ?? null;
// Handle legacy field types as providers
if (!$providerName && isset($field['type'])) {
$fieldType = $field['type'];
if (CaptchaFactory::hasProvider($fieldType)) {
$providerName = $fieldType;
}
}
if (!$providerName) {
// Default to recaptcha for backward compatibility
$providerName = 'recaptcha';
}
$provider = CaptchaFactory::getProvider($providerName);
if (!$provider) {
return [
'provider' => $providerName,
'error' => "Unknown captcha provider: {$providerName}"
];
}
return $provider->getClientProperties($formId, $field);
}
/**
* Get template name for a captcha field
*
* @param array $field Field definition
* @return string Template name
*/
public static function getTemplateName(array $field): string
{
$providerName = $field['provider'] ?? null;
// Handle legacy field types as providers
if (!$providerName && isset($field['type'])) {
$fieldType = $field['type'];
if (CaptchaFactory::hasProvider($fieldType)) {
$providerName = $fieldType;
}
}
if (!$providerName) {
// Default to recaptcha for backward compatibility
$providerName = 'recaptcha';
}
$provider = CaptchaFactory::getProvider($providerName);
if (!$provider) {
return 'forms/fields/captcha/default.html.twig';
}
return $provider->getTemplateName();
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Grav\Plugin\Form\Captcha;
/**
* Interface for captcha providers
*/
interface CaptchaProviderInterface
{
/**
* Validate a captcha response
*
* @param array $form Form data array
* @param array $params Optional parameters
* @return array Validation result with 'success' key and optional 'error' and 'details' keys
*/
public function validate(array $form, array $params = []): array;
/**
* Get client-side properties for the captcha
*
* @param string $formId Form ID
* @param array $field Field definition
* @return array Client properties
*/
public function getClientProperties(string $formId, array $field): array;
/**
* Get the template name for the captcha field
*
* @return string
*/
public function getTemplateName(): string;
}

View File

@@ -0,0 +1,252 @@
<?php
namespace Grav\Plugin\Form\Captcha;
use Grav\Common\Grav;
use Grav\Common\Uri;
/**
* Google reCAPTCHA provider implementation
*/
class ReCaptchaProvider implements CaptchaProviderInterface
{
/** @var array */
protected $config;
public function __construct()
{
$this->config = Grav::instance()['config']->get('plugins.form.recaptcha', []);
}
/**
* {@inheritdoc}
*/
public function validate(array $form, array $params = []): array
{
$grav = Grav::instance();
$uri = $grav['uri'];
$ip = Uri::ip();
$hostname = $uri->host();
try {
$secretKey = $params['recaptcha_secret'] ?? $params['recatpcha_secret'] ??
$this->config['secret_key'] ?? null;
$defaultVersion = $this->normalizeVersion($this->config['version'] ?? '2-checkbox');
$version = $this->normalizeVersion($params['recaptcha_version'] ?? $defaultVersion);
$payloadVersion = $this->detectVersionFromPayload($form);
if ($payloadVersion !== null) {
$version = $payloadVersion;
}
if (!$secretKey) {
throw new \RuntimeException("reCAPTCHA secret key not configured.");
}
$requestMethod = extension_loaded('curl') ? new \ReCaptcha\RequestMethod\CurlPost() : null;
$recaptcha = new \ReCaptcha\ReCaptcha($secretKey, $requestMethod);
// Handle V3
if ($version === '3') {
// For V3, look for token in both top level and data[] structure
$token = $form['token'] ?? ($form['data']['token'] ?? null);
$action = $form['action'] ?? ($form['data']['action'] ?? null);
if (!$token) {
$grav['log']->debug('reCAPTCHA validation failed: token missing for v3');
return [
'success' => false,
'error' => 'missing-input-response',
'details' => ['error' => 'missing-input-response', 'version' => 'v3']
];
}
$recaptcha->setExpectedHostname($hostname);
// Set action if provided
if ($action) {
$recaptcha->setExpectedAction($action);
}
// Set score threshold
$recaptcha->setScoreThreshold($this->config['score_threshold'] ?? 0.5);
}
// Handle V2 (both checkbox and invisible)
else {
// For V2, look for standard response parameter
$token = $form['g-recaptcha-response'] ?? ($form['data']['g-recaptcha-response'] ?? null);
if (!$token) {
$post = $grav['uri']->post();
if (is_array($post)) {
if (isset($post['g-recaptcha-response'])) {
$token = $post['g-recaptcha-response'];
} elseif (isset($post['g_recaptcha_response'])) {
$token = $post['g_recaptcha_response'];
} elseif (isset($post['data']) && is_array($post['data'])) {
if (isset($post['data']['g-recaptcha-response'])) {
$token = $post['data']['g-recaptcha-response'];
} elseif (isset($post['data']['g_recaptcha_response'])) {
$token = $post['data']['g_recaptcha_response'];
}
}
}
}
if (!$token) {
$grav['log']->debug('reCAPTCHA validation failed: g-recaptcha-response missing for v2');
return [
'success' => false,
'error' => 'missing-input-response',
'details' => ['error' => 'missing-input-response', 'version' => 'v2']
];
}
$recaptcha->setExpectedHostname($hostname);
}
// Log validation attempt
$grav['log']->debug('reCAPTCHA validation attempt for version ' . $version);
$validationResponseObject = $recaptcha->verify($token, $ip);
$isValid = $validationResponseObject->isSuccess();
if (!$isValid) {
$errorCodes = $validationResponseObject->getErrorCodes();
$grav['log']->debug('reCAPTCHA validation failed: ' . json_encode($errorCodes));
return [
'success' => false,
'error' => 'validation-failed',
'details' => ['error-codes' => $errorCodes, 'version' => $version]
];
}
// For V3, check if score is available and log it (helpful for debugging/tuning)
if ($version === '3' && method_exists($validationResponseObject, 'getScore')) {
$score = $validationResponseObject->getScore();
$grav['log']->debug('reCAPTCHA v3 validation successful with score: ' . $score);
} else {
$grav['log']->debug('reCAPTCHA validation successful');
}
return [
'success' => true
];
} catch (\Exception $e) {
$grav['log']->error('reCAPTCHA validation error: ' . $e->getMessage());
return [
'success' => false,
'error' => $e->getMessage(),
'details' => ['exception' => get_class($e)]
];
}
}
/**
* Normalize version values to the internal format we use elsewhere.
*/
protected function normalizeVersion($version): string
{
if ($version === null || $version === '') {
return '2-checkbox';
}
if ($version === 3 || $version === '3') {
return '3';
}
if ($version === 2 || $version === '2') {
return '2-checkbox';
}
return (string) $version;
}
/**
* Infer the recaptcha version from the submitted payload when possible.
*/
protected function detectVersionFromPayload(array $form): ?string
{
$formData = isset($form['data']) && is_array($form['data']) ? $form['data'] : [];
$grav = Grav::instance();
$config = $grav['config'];
if ($config->get('plugins.form.debug')) {
try {
$grav['log']->debug('reCAPTCHA payload inspection', [
'top_keys' => array_keys($form),
'data_keys' => array_keys($formData),
]);
} catch (\Throwable $e) {
// Ignore logging issues, detection should continue.
}
}
if (array_key_exists('token', $form) || array_key_exists('token', $formData)) {
return '3';
}
if (array_key_exists('g-recaptcha-response', $form) || array_key_exists('g-recaptcha-response', $formData)) {
return '2-checkbox';
}
if (array_key_exists('g_recaptcha_response', $form) || array_key_exists('g_recaptcha_response', $formData)) {
// Support alternative key naming just in case
return '2-checkbox';
}
return null;
}
/**
* {@inheritdoc}
*/
public function getClientProperties(string $formId, array $field): array
{
$siteKey = $field['recaptcha_site_key'] ?? $this->config['site_key'] ?? null;
$theme = $field['recaptcha_theme'] ?? $this->config['theme'] ?? 'light';
$version = $this->normalizeVersion($field['recaptcha_version'] ?? $this->config['version'] ?? '2-checkbox');
// Determine which version we're using
$isV3 = $version === '3';
$isInvisible = $version === '2-invisible';
// Log the configuration to help with debugging
$grav = Grav::instance();
$grav['log']->debug("reCAPTCHA config for form {$formId}: version={$version}, siteKey=" .
(empty($siteKey) ? 'MISSING' : 'configured'));
return [
'provider' => 'recaptcha',
'siteKey' => $siteKey,
'theme' => $theme,
'version' => $version,
'isV3' => $isV3,
'isInvisible' => $isInvisible,
'containerId' => "g-recaptcha-{$formId}",
'scriptUrl' => "https://www.google.com/recaptcha/api.js" . ($isV3 ? '?render=' . $siteKey : ''),
'initFunctionName' => "initRecaptcha_{$formId}"
];
}
/**
* {@inheritdoc}
*/
public function getTemplateName(): string
{
// Different templates based on version
$version = $this->normalizeVersion($this->config['version'] ?? '2-checkbox');
$isV3 = $version === '3';
$isInvisible = $version === '2-invisible';
if ($isV3) {
return 'forms/fields/recaptcha/recaptchav3.html.twig';
} elseif ($isInvisible) {
return 'forms/fields/recaptcha/recaptcha-invisible.html.twig';
}
return 'forms/fields/recaptcha/recaptcha.html.twig';
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace Grav\Plugin\Form\Captcha;
use Grav\Common\Grav;
use Grav\Common\Uri;
use Grav\Common\HTTP\Client;
/**
* Cloudflare Turnstile provider implementation
*/
class TurnstileProvider implements CaptchaProviderInterface
{
/** @var array */
protected $config;
public function __construct()
{
$this->config = Grav::instance()['config']->get('plugins.form.turnstile', []);
}
/**
* {@inheritdoc}
*/
public function validate(array $form, array $params = []): array
{
$grav = Grav::instance();
$uri = $grav['uri'];
$ip = Uri::ip();
$grav['log']->debug('Turnstile validation - entire form data: ' . json_encode(array_keys($form)));
try {
$secretKey = $params['turnstile_secret'] ??
$this->config['secret_key'] ?? null;
if (!$secretKey) {
$grav['log']->error("Turnstile secret key not configured.");
throw new \RuntimeException("Turnstile secret key not configured.");
}
// First check $_POST directly, then fallback to form data
$token = $_POST['cf-turnstile-response'] ?? null;
if (!$token) {
$token = $form['cf-turnstile-response'] ?? null;
}
// Log raw POST data for debugging
$grav['log']->debug('Turnstile validation - raw POST data keys: ' . json_encode(array_keys($_POST)));
$grav['log']->debug('Turnstile validation - token present: ' . ($token ? 'YES' : 'NO'));
if ($token) {
$grav['log']->debug('Turnstile token length: ' . strlen($token));
}
if (!$token) {
$grav['log']->warning('Turnstile validation failed: missing token response');
return [
'success' => false,
'error' => 'missing-input-response',
'details' => ['error' => 'missing-input-response']
];
}
$client = \Grav\Common\HTTP\Client::getClient();
$grav['log']->debug('Turnstile validation - calling API with token');
$response = $client->request('POST', 'https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'body' => [
'secret' => $secretKey,
'response' => $token,
'remoteip' => $ip
]
]);
$statusCode = $response->getStatusCode();
$grav['log']->debug('Turnstile API response status: ' . $statusCode);
$content = $response->toArray();
$grav['log']->debug('Turnstile API response: ' . json_encode($content));
if (!isset($content['success'])) {
$grav['log']->error("Invalid response from Turnstile verification (missing 'success' key).");
throw new \RuntimeException("Invalid response from Turnstile verification (missing 'success' key).");
}
if (!$content['success']) {
$grav['log']->warning('Turnstile validation failed: ' . json_encode($content));
return [
'success' => false,
'error' => 'validation-failed',
'details' => ['error-codes' => $content['error-codes'] ?? ['validation-failed']]
];
}
$grav['log']->debug('Turnstile validation successful');
return [
'success' => true
];
} catch (\Exception $e) {
$grav['log']->error("Turnstile validation error: " . $e->getMessage());
return [
'success' => false,
'error' => $e->getMessage(),
'details' => ['exception' => get_class($e)]
];
}
}
/**
* {@inheritdoc}
*/
public function getClientProperties(string $formId, array $field): array
{
$siteKey = $field['turnstile_site_key'] ?? $this->config['site_key'] ?? null;
$theme = $field['turnstile_theme'] ?? $this->config['theme'] ?? 'auto';
return [
'provider' => 'turnstile',
'siteKey' => $siteKey,
'theme' => $theme,
'containerId' => "cf-turnstile-{$formId}",
'scriptUrl' => "https://challenges.cloudflare.com/turnstile/v0/api.js",
'initFunctionName' => "initTurnstile_{$formId}"
];
}
/**
* {@inheritdoc}
*/
public function getTemplateName(): string
{
return 'forms/fields/turnstile/turnstile.html.twig';
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
<?php declare(strict_types=1);
namespace Grav\Plugin\Form;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Page;
use Grav\Framework\Form\Interfaces\FormFactoryInterface;
use Grav\Framework\Form\Interfaces\FormInterface;
class FormFactory implements FormFactoryInterface
{
/**
* Create form using the header of the page.
*
* @param Page $page
* @param string $name
* @param array $form
* @return Form|null
* @deprecated 1.6 Use FormFactory::createFormByPage() instead.
*/
public function createPageForm(Page $page, string $name, array $form): ?FormInterface
{
return new Form($page, $name, $form);
}
/**
* Create form using the header of the page.
*
* @param PageInterface $page
* @param string $name
* @param array $form
* @return Form|null
*/
public function createFormForPage(PageInterface $page, string $name, array $form): ?FormInterface
{
return new Form($page, $name, $form);
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace Grav\Plugin\Form;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Page;
use Grav\Framework\Form\Interfaces\FormFactoryInterface;
use Grav\Framework\Form\Interfaces\FormInterface;
class Forms
{
/** @var array|FormFactoryInterface[] */
private $types;
/** @var FormInterface|null */
private $form;
/**
* Forms constructor.
*/
public function __construct()
{
$this->registerType('form', new FormFactory());
}
/**
* @param string $type
* @param FormFactoryInterface $factory
*/
public function registerType(string $type, FormFactoryInterface $factory): void
{
$this->types[$type] = $factory;
}
/**
* @param string $type
*/
public function unregisterType($type): void
{
unset($this->types[$type]);
}
/**
* @param string $type
* @return bool
*/
public function hasType(string $type): bool
{
return isset($this->types[$type]);
}
/**
* @return array
*/
public function getTypes(): array
{
return array_keys($this->types);
}
/**
* @param PageInterface $page
* @param string|null $name
* @param array|null $form
* @return FormInterface|null
*/
public function createPageForm(PageInterface $page, ?string $name = null, ?array $form = null): ?FormInterface
{
if (null === $form) {
[$name, $form] = $this->getPageParameters($page, $name);
}
if (null === $form) {
return null;
}
$type = $form['type'] ?? 'form';
$factory = $this->types[$type] ?? null;
if ($factory) {
if (is_callable([$factory, 'createFormForPage'])) {
return $factory->createFormForPage($page, $name, $form);
}
if ($page instanceof Page) {
// @phpstan-ignore-next-line
return $factory->createPageForm($page, $name, $form);
}
}
return null;
}
/**
* @return FormInterface|null
*/
public function getActiveForm(): ?FormInterface
{
return $this->form;
}
/**
* @param FormInterface $form
* @return void
*/
public function setActiveForm(FormInterface $form): void
{
$this->form = $form;
}
/**
* @param PageInterface $page
* @param string|null $name
* @return array
*/
protected function getPageParameters(PageInterface $page, ?string $name): array
{
$forms = $page->getForms();
if ($name) {
// If form with given name was found, use that.
$form = $forms[$name] ?? null;
} else {
// Otherwise pick up the first form.
$form = reset($forms) ?: null;
$name = (string)key($forms);
}
return [$name, $form];
}
}

View File

@@ -0,0 +1,163 @@
<?php declare(strict_types=1);
namespace Grav\Plugin\Form;
use Grav\Framework\Form\Interfaces\FormInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
use function is_string;
/**
* Class GravExtension
* @package Grav\Common\Twig\Extension
*/
class TwigExtension extends AbstractExtension
{
public function getFilters()
{
return [
new TwigFilter('value_and_label', [$this, 'valueAndLabel'])
];
}
/**
* Return a list of all functions.
*
* @return array
*/
public function getFunctions(): array
{
return [
new TwigFunction('prepare_form_fields', [$this, 'prepareFormFields'], ['needs_context' => true]),
new TwigFunction('prepare_form_field', [$this, 'prepareFormField'], ['needs_context' => true]),
new TwigFunction('include_form_field', [$this, 'includeFormField']),
];
}
public function valueAndLabel($value): array
{
if (!is_array($value)) {
return [];
}
$list = [];
foreach ($value as $key => $label) {
$list[] = ['value' => $key, 'label' => $label];
}
return $list;
}
/**
* Filters form fields for the current parent.
*
* @param array $context
* @param array $fields Form fields
* @param string|null $parent Parent field name if available
* @return array
*/
public function prepareFormFields(array $context, $fields, $parent = null): array
{
$list = [];
if (is_iterable($fields)) {
foreach ($fields as $name => $field) {
$field = $this->prepareFormField($context, $field, $name, $parent);
if ($field) {
$list[$field['name']] = $field;
}
}
}
return $list;
}
/**
* Filters field name by changing dot notation into array notation.
*
* @param array $context
* @param array $field Form field
* @param string|int|null $name Field name (defaults to field.name)
* @param string|null $parent Parent field name if available
* @param array|null $options List of options to override
* @return array|null
*/
public function prepareFormField(array $context, $field, $name = null, $parent = null, array $options = []): ?array
{
// Make sure that the field is a valid form field type and is not being ignored.
if (empty($field['type']) || ($field['validate']['ignore'] ?? false)) {
return null;
}
// If field has already been prepared, we do not need to do anything.
if (!empty($field['prepared'])) {
return $field;
}
// Check if we have just a list of fields (no name given).
$fieldName = (string)($field['name'] ?? $name);
if (!is_string($name) || $name === '') {
// Look at the field.name and if not set, fall back to the key.
$name = $fieldName;
}
// Make sure that the field has a name.
if ($name === '') {
return null;
}
// Prefix name with the parent name if needed.
if (str_starts_with($name, '.')) {
$plainName = (string)substr($name, 1);
$field['plain_name'] = $plainName;
$name = $parent ? $parent . $name : $plainName;
} elseif (isset($options['key'])) {
$name = str_replace('*', $options['key'], $name);
}
unset($options['key']);
// Set fields as readonly if form is in readonly mode.
/** @var FormInterface $form */
$form = $context['form'] ?? null;
if ($form && method_exists($form, 'isEnabled') && !$form->isEnabled()) {
$options['disabled'] = true;
}
// Loop through options
foreach ($options as $key => $option) {
$field[$key] = $option;
}
// Always set field name.
$field['name'] = $name;
$field['prepared'] = true;
return $field;
}
/**
* @param string $type
* @param string|string[]|null $layouts
* @param string|null $default
* @return string[]
*/
public function includeFormField(string $type, $layouts = null, ?string $default = null): array
{
$list = [];
foreach ((array)$layouts as $layout) {
$list[] = "forms/fields/{$type}/{$layout}-{$type}.html.twig";
}
$list[] = "forms/fields/{$type}/{$type}.html.twig";
if ($default) {
foreach ((array)$layouts as $layout) {
$list[] = "forms/fields/{$default}/{$layout}-{$default}.html.twig";
}
$list[] = "forms/fields/{$default}/{$default}.html.twig";
}
return $list;
}
}

View File

@@ -0,0 +1,40 @@
{
"name": "getgrav/grav-plugin-form",
"type": "grav-plugin",
"description": "Grav Plugin Form",
"keywords": ["form"],
"homepage": "https://github.com/getgrav/grav-plugin-form",
"license": "MIT",
"authors": [
{
"name": "Team Grav",
"email": "devs@getgrav.org",
"homepage": "https://getgrav.org",
"role": "Developer"
}
],
"support": {
"issues": "https://github.com/getgrav/grav-plugin-form/issues",
"irc": "https://chat.getgrav.org",
"forum": "https://discourse.getgrav.org",
"docs": "https://github.com/getgrav/grav-plugin-form/blob/master/README.md"
},
"require": {
"php": "^7.3.6 || ^8.0",
"ext-json": "*",
"google/recaptcha": "^1.2"
},
"autoload": {
"psr-4": {
"Grav\\Plugin\\Form\\": "classes/"
},
"classmap": [
"form.php"
]
},
"config": {
"platform": {
"php": "7.3.6"
}
}
}

View File

@@ -0,0 +1,77 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "e0e2ccc2fca6a3cb5d548cf61bb80f66",
"packages": [
{
"name": "google/recaptcha",
"version": "1.2.4",
"source": {
"type": "git",
"url": "https://github.com/google/recaptcha.git",
"reference": "614f25a9038be4f3f2da7cbfd778dc5b357d2419"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/google/recaptcha/zipball/614f25a9038be4f3f2da7cbfd778dc5b357d2419",
"reference": "614f25a9038be4f3f2da7cbfd778dc5b357d2419",
"shasum": ""
},
"require": {
"php": ">=5.5"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.2.20|^2.15",
"php-coveralls/php-coveralls": "^2.1",
"phpunit/phpunit": "^4.8.36|^5.7.27|^6.59|^7.5.11"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.2.x-dev"
}
},
"autoload": {
"psr-4": {
"ReCaptcha\\": "src/ReCaptcha"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "Client library for reCAPTCHA, a free service that protects websites from spam and abuse.",
"homepage": "https://www.google.com/recaptcha/",
"keywords": [
"Abuse",
"captcha",
"recaptcha",
"spam"
],
"support": {
"forum": "https://groups.google.com/forum/#!forum/recaptcha",
"issues": "https://github.com/google/recaptcha/issues",
"source": "https://github.com/google/recaptcha"
},
"time": "2020-03-31T17:50:54+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^7.3.6 || ^8.0",
"ext-json": "*"
},
"platform-dev": [],
"platform-overrides": {
"php": "7.3.6"
},
"plugin-api-version": "2.2.0"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
enabled: true
built_in_css: true
inline_css: true
refresh_prevention: false
client_side_validation: true
debug: false
inline_errors: false
modular_form_fix: true
files:
multiple: false # To allow multiple files, default is single
limit: 10 # Number of allowed files per field (multiple required)
destination: 'self@' # Where to upload the files (path and self@, page@, theme@)
avoid_overwriting: false # Prevent files with the same name to be overridden. Date prefix will be added
random_name: false # Generate a random 15 long string name for the uploaded files
filesize: 0 # Maximum file size allowed (in MB)
accept: # List of mime/types or file extensions allowed (ie, image/*,.zip,.mp4)
- image/*
recaptcha:
version: 2-checkbox
theme: light
site_key:
secret_key:
turnstile:
theme: light # options: [light | dark]
site_key:
secret_key:
basic_captcha:
type: math # Options: dotcount, position, math, characters
debug: false # Enable debug logging for troubleshooting
# Image settings
image:
width: 135 # Image width (default: 135 for classic size)
height: 40 # Image height (default: 40 for classic size)
bg: '#ffffff' # Background color
# Character captcha settings (used for the 'characters' type)
chars:
length: 6 # Number of characters to display
font: zxx-xed.ttf # Font file in the plugin's fonts directory
size: 24 # Font size
box_width: 200 # Image width
box_height: 70 # Image height
start_x: 10 # Starting X position for text
start_y: 40 # Starting Y position for text
bg: '#ffffff' # Background color
text: '#000000' # Text color
# Math puzzle settings (used for the 'math' type)
math:
min: 1 # Minimum number value
max: 12 # Maximum number value
operators: ['+','-','*'] # Available operators

View File

@@ -0,0 +1,84 @@
'use strict';
var gulp = require('gulp');
var util = require('util');
var path = require('path');
var immutable = require('immutable');
var gulpWebpack = require('gulp-webpack');
var webpack = require('webpack');
var sourcemaps = require('gulp-sourcemaps');
var exec = require('child_process').execSync;
var sass = require('gulp-sass');
var cleancss = require('gulp-clean-css');
var csscomb = require('gulp-csscomb');
var rename = require('gulp-rename');
var autoprefixer = require('gulp-autoprefixer');
var pwd = exec('pwd').toString();
// configure the paths
var watch_dir = './scss/**/*.scss';
var src_dir = './scss/*.scss';
var dest_dir = './assets';
var paths = {
source: src_dir
};
var plugins = {};
var base = immutable.fromJS(require('./webpack.conf.js'));
var options = {
prod: base.mergeDeep({
devtool: 'source-map',
optimization: {minimize: true},
plugins: [
new webpack.DefinePlugin({
'process.env': { NODE_ENV: '"production"' }
}),
new webpack.ProvidePlugin(plugins)
],
output: {
filename: 'form.min.js'
}
})
};
// var compileJS = function(watch) {
// var prodOpts = options.prod.set('watch', watch);
//
// return gulp.src('app/main.js')
// .pipe(gulpWebpack(prodOpts.toJS()))
// .pipe(gulp.dest('assets/'));
// };
var compileCSS = function() {
return gulp.src(paths.source)
.pipe(sourcemaps.init())
.pipe(sass({outputStyle: 'compact', precision: 10})
.on('error', sass.logError)
)
.pipe(sourcemaps.write())
.pipe(autoprefixer())
.pipe(gulp.dest(dest_dir))
.pipe(csscomb())
.pipe(cleancss())
.pipe(rename({
suffix: '.min'
}))
.pipe(gulp.dest(dest_dir));
};
// gulp.task('js', function() {
// compileJS(false);
// });
gulp.task('css', function() {
compileCSS();
});
gulp.task('watch', function() {
gulp.watch(watch_dir, ['css']);
// compileJS(true);
});
gulp.task('all', ['css']);
gulp.task('default', ['all']);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
{
"name": "grav-plugin-form",
"version": "1.0.0",
"description": "Grav Plugin Form",
"repository": "https://github.com/getgrav/grav-plugin-form",
"main": "app/main.js",
"scripts": {
"watch": "NODE_ENV=development webpack --watch --progress --colors --mode development --config webpack.conf.js",
"dev": "NODE_ENV=development webpack --progress --colors --config webpack.conf.js",
"prod": "NODE_ENV=production-wip webpack --mode development --config webpack.conf.js"
},
"author": "Team Grav",
"license": "MIT",
"dependencies": {
"dropzone": "getgrav/dropzone#master",
"exif-js": "^2.3.0",
"filepond": "^4.32.7",
"filepond-plugin-file-validate-size": "^2.2.8",
"filepond-plugin-file-validate-type": "^1.2.9",
"filepond-plugin-image-preview": "^4.6.12",
"filepond-plugin-image-resize": "^2.0.10",
"sortablejs": "^1.10.2"
},
"devDependencies": {
"@babel/core": "^7.9.0",
"@babel/preset-env": "^7.9.0",
"babel-eslint": "^10.1.0",
"babel-loader": "^8.1.0",
"babel-polyfill": "^6.26.0",
"css-loader": "^7.1.2",
"eslint": "^6.8.0",
"eslint-loader": "^3.0.4",
"exports-loader": "^0.7.0",
"gulp": "^5.0.0",
"gulp-autoprefixer": "^9.0.0",
"gulp-clean-css": "^4.3.0",
"gulp-csscomb": "^0.1.0",
"gulp-rename": "^2.0.0",
"gulp-webpack": "^0.0.1",
"immutable": "^4.0.0-rc.12",
"imports-loader": "^0.8.0",
"json-loader": "^0.5.7",
"style-loader": "^1.1.3",
"terser-webpack-plugin": "^5.3.10",
"webpack": "^5.98.0",
"webpack-cli": "^6.0.1"
}
}

View File

@@ -0,0 +1,2 @@
#!/bin/sh
sass --watch -s compressed scss:assets

View File

@@ -0,0 +1,347 @@
$form-border-color: #ccc;
$form-active-color: #000;
.form-group.has-errors {
background: rgba(255,0,0,0.05);
border: 1px solid rgba(255,0,0,0.2);
border-radius: 3px;
margin: 0 -5px;
padding: 0 5px;
}
.form-errors {
color: #b52b27;
}
.form-honeybear {
display: none;
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
clip-path: rect(0px, 1px, 1px, 0px);
}
.form-errors p {
margin: 0;
}
.form-input-file {
input {
display: none;
}
.dz-default.dz-message {
position: absolute;
text-align: center;
left: 0;
right: 0;
top: 50%;
transform: translateY(-50%);
margin: 0;
}
&.dropzone {
position: relative;
min-height: 70px;
border-radius: 3px;
margin-bottom: .85rem;
border: 2px dashed #ccc;
color: #aaa;
padding: 0.5rem;
.dz-preview {
margin: 0.5rem;
&:hover {
z-index: 2;
}
.dz-image img {
margin: 0;
}
.dz-remove {
font-size: 16px;
position: absolute;
top: 3px;
right: 3px;
display: inline-flex;
height: 20px;
width: 20px;
background-color: red;
justify-content: center;
align-items: center;
color: white;
font-weight: bold;
border-radius: 50%;
cursor: pointer;
z-index: 20;
&:hover {
background-color: darkred;
text-decoration: none;
}
}
.dz-error-message {
min-width: 140px;
width: auto;
}
.dz-image,
&.dz-file-preview .dz-image {
border-radius: 3px;
z-index: 1;
}
}
}
}
//Filepond
.filepond--root.form-input {
min-height: 7rem;
height: auto;
overflow:hidden;
border: 0;
}
// New JS powered tabs
.form-tabs {
.tabs-nav {
display: flex;
padding-top: 1px;
margin-bottom: -1px;
a {
flex: 1;
transition: color 0.5s ease, background 0.5s ease;
cursor: pointer;
text-align:center;
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid $form-border-color;
border-radius: 5px 5px 0 0;
&.active {
border: 1px solid $form-border-color;
border-bottom: 1px solid transparent;
margin: 0 -1px;
span {
color: $form-active-color;
}
}
}
span {
display: inline-block;
line-height: 1.1;
}
}
&.subtle .tabs-nav {
margin-right: 0 !important;
}
.tabs-content {
.tab__content {
display: none;
padding-top: 2rem;
&.active {
display: block;
}
}
}
}
// Checkboxes
.checkboxes {
display: inline-block;
label {
display: inline;
cursor: pointer;
position: relative;
padding: 0 0 0 20px;
margin-right: 15px;
}
label:before {
content:"";
display: inline-block;
width: 20px;
height: 20px;
left: 0;
margin-top: 0;
margin-right: 10px;
position: absolute;
border-radius: 3px;
border: 1px solid #e6e6e6;
}
input[type=checkbox] {
display: none;
}
input[type=checkbox]:checked + label:before {
content:"\2713";
font-size: 20px;
line-height: 1;
text-align: center;
}
&.toggleable label{
margin-right: 0;
}
}
// Toggleable
.form-field-toggleable {
.checkboxes.toggleable {
margin-right: 5px;
vertical-align: middle;
}
.checkboxes + label {
display: inline-block;
}
}
// Toggles
.switch-toggle {
display: inline-flex;
overflow: hidden;
border-radius: 3px;
line-height: 35px;
border: 1px solid $form-border-color;
input[type=radio] {
position: absolute;
visibility: hidden;
display: none;
}
label {
display: inline-block;
cursor: pointer;
padding: 0 15px;
margin: 0;
white-space: nowrap;
color: inherit;
transition: background-color 0.5s ease;
}
input.highlight:checked + label {
background: #333;
color: #fff;
}
input:checked + label {
color: #fff;
background: #999;
}
}
/* Signature Pad */
.signature-pad {
position: relative;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
font-size: 10px;
width: 100%;
height: 100%;
max-width: 700px;
max-height: 460px;
border: 1px solid #f0f0f0;
background-color: #fff;
padding: 16px;
}
.signature-pad--body {
position: relative;
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
border: 1px solid #f6f6f6;
min-height: 100px;
}
.signature-pad--body canvas {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border-radius: 4px;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.02) inset;
}
.signature-pad--footer {
color: #C3C3C3;
text-align: center;
font-size: 1.2em;
}
.signature-pad--actions {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: justify;
-ms-flex-pack: justify;
justify-content: space-between;
margin-top: 8px;
}
[data-grav-field="array"] .form-row {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
}
[data-grav-field="array"] .form-row > input,
[data-grav-field="array"] .form-row > textarea
{
margin: 0 0.5rem;
display: inline-block;
}
.form-data.basic-captcha {
.form-input-wrapper {
border: 1px solid $form-border-color;
border-radius: 5px;
display: flex;
overflow: hidden;
}
.form-input-prepend {
display: flex;
color: #333;
background-color: #ccc;
flex-shrink: 0;
img {
margin: 0;
}
button > svg {
margin: 0 8px;
width: 18px;
height: 18px;
}
}
input.form-input {
border: 0;
}
}

View File

@@ -0,0 +1,2 @@
{% include 'partials/form-messages.html.twig' %}
{% do http_response_code(form.responseCode) %}

View File

@@ -0,0 +1,2 @@
{% include 'partials/form-messages.json.twig' %}
{% do http_response_code(form.responseCode) %}

View File

@@ -0,0 +1 @@
{% extends "forms/default/form.html.twig" %}

View File

@@ -0,0 +1,8 @@
{% extends 'partials/base.html.twig' %}
{% block content %}
{{ content|raw }}
{% include "forms/form.html.twig" %}
{% endblock %}

View File

@@ -0,0 +1 @@
{% extends 'forms/ajax.json.twig' %}

View File

@@ -0,0 +1,20 @@
{% extends 'partials/base.html.twig' %}
{% if form is null %}
{% set form = grav.session.getFlashObject('form') %}
{% endif %}
{% block content %}
{{ content|raw }}
{% if form %}
{% include 'partials/form-messages.html.twig' %}
<p>{{ 'PLUGIN_FORM.DATA_SUMMARY'|t }}</p>
{% include "forms/data.html.twig" %}
{% else %}
<div class="notices warning yellow"><p>{{ 'PLUGIN_FORM.NO_FORM_DATA'|t }}</p></div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,5 @@
{% if form_json_response %}
{{ form_json_response|json_encode|raw }}
{% else %}
{}
{% endif %}

View File

@@ -0,0 +1 @@
{% extends "forms/default/data.html.twig" %}

View File

@@ -0,0 +1 @@
{% extends "forms/default/data.txt.twig" %}

View File

@@ -0,0 +1,83 @@
{% macro render_field(form, fields, scope) %}
{% import _self as self %}
{% for index, field in fields %}
{%- set show_field = attribute(field, "input@") ?? field.store ?? true %}
{% if field.fields %}
{%- set new_scope = field.nest_id ? scope ~ field.name ~ '.' : scope -%}
{{- self.render_field(form, field.fields, new_scope) }}
{% else %}
{% if show_field %}
{%- set value = form.value(scope ~ (field.name ?? index)) -%}
{% if value %}
{% block field %}
<div>
{% block field_label %}
<strong>
{%- if field.markdown -%}
{{ field.data_label ? field.data_label|t|e|markdown(false) : field.label|t|emarkdown(false) }}
{%- else -%}
{{ field.data_label ? field.data_label|t|e : field.label|t|e }}
{%- endif -%}
</strong>:
{% endblock %}
{% block field_value %}
{% if field.type == 'checkboxes' %}
<ul>
{% set use_keys = field.use is defined and field.use == 'keys' %}
{% for key,value in form.value(scope ~ field.name) %}
{% set index = (use_keys ? key : value) %}
<li>{{ field.options[index]|t|e }}</li>
{% endfor %}
</ul>
{% elseif field.type == 'radio' %}
{% set value = form.value(scope ~ field.name) %}
{{ field.options[value]|t|e }}
{% elseif field.type == 'checkbox' %}
{{ (form.value(scope ~ field.name) == 1) ? "GRAV.YES"|t|e : "GRAV.NO"|t|e }}
{% elseif field.type == 'select' %}
{% set value = form.value(scope ~ field.name) %}
{% if value is iterable %}
<ul>
{% set use_keys = field.use is defined and field.use == 'keys' %}
{% for key, val in value %}
{% set index = (use_keys ? key : val) %}
<li>{{ field.options[index]|t|e }}</li>
{% endfor %}
</ul>
{% else %}
{{ field.options[value]|t|e }}
{% endif %}
{% else %}
{% set value = form.value(scope ~ field.name) %}
{% if value is iterable %}
<ul>
{% for val in value %}
{% if val is iterable %}
<ul>
{% for v in val %}
<li>{{ string(v)|e }}</li>
{% endfor %}
</ul>
{% else %}
<li>{{ string(val)|e }}</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
{{ string(value)|e|nl2br }}
{% endif %}
{% endif %}
{% endblock %}
</div>
{% endblock %}
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
{% endmacro %}
{% import _self as macro %}
{{ macro.render_field(form, form.fields, '') }}

View File

@@ -0,0 +1,21 @@
{%- macro render_field(form, fields, scope) %}
{%- import _self as self %}
{%- for index, field in fields %}
{%- set show_field = attribute(field, "input@") ?? field.store ?? true %}
{%- if field.fields %}
{%- set new_scope = field.nest_id ? scope ~ field.name ~ '.' : scope -%}
{{- self.render_field(form, field.fields, new_scope) }}
{%- else %}
{%- if show_field %}
{%- set value = form.value(scope ~ (field.name ?? index)) -%}
{%- if value -%}
{{- field.data_label ? field.data_label|t|e : field.label|t|e }}: {{ string(value is iterable ? value|json_encode : value) ~ "\n" }}
{%- endif -%}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- endmacro %}
{%- import _self as macro %}
{%- autoescape false %}
{{- macro.render_field(form, form.fields, '') ~ "\n" }}
{%- endautoescape %}

View File

@@ -0,0 +1,158 @@
{% if not field.validate.ignore %}
{% use 'forms/layouts/field-variables.html.twig' %}
{% block field_override_variables_before %}{% endblock %}
{% set field_name = (scope ~ field.name)|fieldName %}
{% set vertical = field.style == 'vertical' %}
{% if not blueprints or (blueprints.schema.type(field.type)['input@'] ?? true) is same as(true) %}
{% set default = field.default %}
{% set toggleable = field.toggleable ?? false %}
{% if toggleable %}
{% set originalValue = originalValue ?? value %}
{% set toggleableChecked = originalValue is not null %}
{% elseif field.overridable %}
{% set toggleable = true %}
{% set default = form.getDefaultValue(field.name) ?? default %}
{% set toggleableChecked = value is not null and value != default %}
{% endif %}
{% set cookie_name = 'forms-' ~ form.name ~ '-' ~ field.name %}
{% set value = value ?? get_cookie(cookie_name) %}
{% set has_value = value is not same as(null) %}
{% if not has_value %}
{% set value = default %}
{% endif %}
{% if (field.yaml or field.validate.type == 'yaml') and value is iterable %}
{% set value = value|yaml %}
{% endif %}
{% else %}
{% set toggleable = false %}
{% endif %}
{# DEPRECATED: Needed by old form fields; remove when backwards compatibility breaks are allowed #}
{% set isDisabledToggleable = toggleable and not toggleableChecked %}
{% if toggleable %}
{% set form_field_toggleable %}
{% include 'forms/default/toggleable.html.twig' with {checked: toggleableChecked} %}
{% endset %}
{% endif %}
{% set errors = attribute(form.messages, field.name) %}
{% set required = client_side_validation and field.validate.required in ['on', 'true', 1] %}
{% set autofocus = (inline_errors == false) and field.autofocus in ['on', 'true', 1] %}
{% if inline_errors and errors %}
{% set autofocus = true %}
{% endif %}
{% set embed_outer_field_classes %}
{% block outer_field_classes %}{% endblock %}
{% endset %}
{# Field Classes #}
{%- if errors %}{% set form_field_outer_core = form_field_outer_core ~ ' has-errors' %}{% endif -%}
{%- if toggleable %}{% set form_field_outer_core = form_field_outer_core ~ ' form-field-toggleable' %}{% endif -%}
{% set layout_form_field_outer_classes = field.outerclasses %}
{% set layout_form_field_outer_classes = layout_form_field_outer_classes|trim ~ ' ' ~ form_field_outer_classes %}
{% set layout_form_field_outer_classes = layout_form_field_outer_classes|trim ~ ' ' ~ embed_outer_field_classes %}
{# Show Label logic #}
{% set show_label = field.label is not same as(false) and field.display_label is not same as(false )%}
{# Label Classes #}
{% set layout_form_field_outer_label_classes = ((form_field_outer_label_classes ?: 'form-label') ~ ' ' ~ field.labelclasses)|trim %}
{% set layout_form_field_label_classes = (form_field_label_classes ?: 'inline')|trim %}
{% set form_field_label_trim = toggleable ? 'toggleable' %}
{# Field Outer Data classes #}
{% set layout_form_field_outer_data_classes = ((form_field_outer_data_classes ?: ' form-data') ~ ' ' ~ field.dataclasses)|trim %}
{# Field Wrapper classes #}
{% set layout_form_field_wrapper_classes = ((form_field_wrapper_classes ?: ' form-input-wrapper') ~ ' ' ~ field.wrapper_classes)|trim %}
{# Field input classes #}
{% if field|of_type('array') %}
{% if field.classes %}
{% set field = field|merge({'classes': field.classes ~ ' ' ~ block('field_input_classes')|trim }) %}
{% else %}
{% set field = field|merge({'classes': block('field_input_classes') }) %}
{% endif %}
{% endif %}
{% set layout_form_field_input_classes = (form_field_input_classes ~ ' ' ~ field.classes)|trim %}
{# Inline error classes #}
{% set form_field_inline_error_classes = form_field_inline_error_classes ?: ' form-errors' %}
{# Field extra classes #}
{% set form_field_extra_wrapper_classes = 'form-extra-wrapper ' ~ field.wrapper_classes %}
{# Field For #}
{% set form_field_for = toggleable ? 'toggleable_' ~ field.name : field.id|e %}
{# Field Label #}
{% set form_field_label = field.markdown ? field.label|markdown(false) : field.label %}
{% set form_field_label = form_field_label|default(field.name|capitalize)|t %}
{# Field Help #}
{% if field.help %}
{% set form_field_help = field.markdown ? field.help|t|markdown(false)|e : field.help|t|e %}
{% endif %}
{# Field Requied #}
{% set form_field_required = field.validate.required in ['on', 'true', 1] ? true : false %}
{# Field Description #}
{% set form_field_description = field.markdown ? field.description|t|markdown(false)|raw : field.description|t|raw %}
{% extends 'forms/layouts/field.html.twig' %}
{% block global_attributes %}
data-grav-field="{{ field.type }}"
data-grav-disabled="{{ toggleable and toggleableChecked }}"
data-grav-default="{{ default|json_encode()|e('html_attr') }}"
{% endblock %}
{% block input_attributes %}
class="{{ layout_form_field_input_classes|trim }} {{ 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|e('html_attr') }}"{% endif %}
{% if autofocus %}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.autocapitalize in ['off', 'characters', 'words', 'sentences'] %}autocapitalize="{{ field.autocapitalize }}"{% endif %}
{% if field.inputmode in ['none', 'text', 'decimal', 'numeric', 'tel', 'search', 'email', 'url'] %}inputmode="{{ field.inputmode }}"{% endif %}
{% if field.tabindex %}tabindex="{{ field.tabindex }}"{% endif %}
{% if field.spellcheck in ['true', 'false'] %}spellcheck="{{ field.spellcheck }}"{% endif %}
{% if required %}required="required"{% endif %}
{% if field.validate.pattern %}pattern="{{ field.validate.pattern|e }}"{% endif %}
{% if field.validate.message %}title="{{ field.validate.message|t|e }}"
{% elseif field.title is defined %}title="{{ field.title|t|e }}" {% endif %}
{# Support key/value and .name/.value styles #}
{% 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 %}
{# Support for Custom data attributes#}
{% if field.datasets %}
{% for key, attribute in field.datasets %}
data-{{ key }}="{{ attribute|e('html_attr') }}"
{% endfor %}
{% endif %}
{% endblock %}
{% endif %}

View File

@@ -0,0 +1,17 @@
{% set fields = prepare_form_fields(fields, name) %}
{% set originalValue = null %}
{% if fields|length %}
{% block outer_markup_field_open %}{% endblock %}
{% for field_name, field in fields %}
{% set value = form ? form.value(field.name) : data.value(field.name) %}
{% set field_templates = include_form_field(field.type, field_layout, fallback_field ?? 'text') %}
{% block inner_markup_field_open %}{% endblock %}
{% block field %}
{% include field_templates %}
{% endblock %}
{% block inner_markup_field_close %}{% endblock %}
{% endfor %}
{% block outer_markup_field_close %}{% endblock %}
{% else %}
{% block empty_fields_markup %}{% endblock %}
{% endif %}

View File

@@ -0,0 +1,211 @@
{% block xhr %}{% endblock %}
{% set form = form ?? grav.session.getFlashObject('form') %}
{% set layout = layout ?? form.layout ?? 'default' %}
{% set field_layout = field_layout ?? layout %}
<div id="{{ form.id }}-wrapper" class="form-wrapper">
{# Keep here for Backwards Compatibility #}
{% include 'partials/form-messages.html.twig' %}
{% set scope = scope ?: form.scope is defined ? form.scope : 'data.' %}
{% set multipart = '' %}
{% set blueprints = blueprints ?? form.blueprint() %}
{% set method = form.method|upper|default('POST') %}
{% set client_side_validation = form.client_side_validation is not null ? form.client_side_validation : config.plugins.form.client_side_validation|defined(true) %}
{% set inline_errors = form.inline_errors is not null ? form.inline_errors : config.plugins.form.inline_errors(false) %}
{% set data = data ?? form.data %}
{% set context = context ?? data %}
{% for field in form.fields %}
{% if (method == 'POST' and field.type == 'file') %}
{% set multipart = ' enctype="multipart/form-data"' %}
{% endif %}
{% endfor %}
{% set action = action ?? (form.action ?: page.route ~ uri.params) %}
{% set action = (action starts with 'http') or (action starts with '#') ? action : base_url ~ action %}
{% set action = action|trim('/', 'right') %}
{% if (action == base_url_relative) %}
{% set action = base_url_relative ~ '/' %}
{% endif %}
{% if form.keep_alive %}
{% if grav.browser.browser == 'msie' and grav.browser.version < 12 %}
{% do assets.addJs('plugin://form/assets/object.assign.polyfill.js') %}
{% endif %}
{% 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 %}
{% do assets.addInlineJs("
window.GravForm = window.GravForm || {};
window.GravForm.config = {
current_url: '" ~ grav.route.withoutParams().toString(true) ~"',
current_params: " ~ grav.route.params|json_encode ~ ",
param_sep: '" ~ config.system.param_sep ~ "',
base_url_relative: '" ~ base_url_relative ~ "',
form_nonce: '" ~ form.getNonce() ~ "',
session_timeout: " ~ config.system.session.timeout ~ "
};
window.GravForm.translations = Object.assign({}, window.GravForm.translations || {}, { PLUGIN_FORM: {} });
", {'group': 'bottom', 'position': 'before', 'priority': 100}) %}
{# Backwards Compatibility for block overrides #}
{% set override_form_classes %}
{% block form_classes -%}
{{ form_outer_classes }} {{ form.classes }}
{%- endblock %}
{% endset %}
{% set override_inner_markup_fields_start %}
{% block inner_markup_fields_start %}{% endblock %}
{% endset %}
{% set override_inner_markup_fields_end %}
{% block inner_markup_fields_end %}{% endblock %}
{% endset %}
{% set override_inner_markup_fields %}
{% block inner_markup_fields %}
{% for field_name, field in form.fields %}
{% set field = prepare_form_field(field, field_name) %}
{% if field %}
{% set value = form ? form.value(field.name) : data.value(field.name) %}
{% set field_templates = include_form_field(field.type, field_layout) %}
{% block inner_markup_field_open %}{% endblock %}
{% block field %}
{% include field_templates ignore missing %}
{% endblock %}
{% block inner_markup_field_close %}{% endblock %}
{% endif %}
{% endfor %}
{% endblock %}
{% endset %}
{% set override_inner_markup_buttons_start %}
{% block inner_markup_buttons_start %}
<div class="{{ form_button_outer_classes ?: 'buttons'}}">
{% endblock %}
{% endset %}
{% set override_inner_markup_buttons_end %}
{% block inner_markup_buttons_end %}
</div>
{% endblock %}
{% endset %}
{# Embed for HTML layout #}
{% embed 'forms/layouts/form.html.twig' %}
{% block embed_form_core %}
name="{{ form.name }}"
action="{{ action }}"
method="{{ method }}"{{ multipart|raw }}
id="{{ form.id|default(form.name|hyphenize) }}"
{% if form.novalidate %}novalidate{% endif %}
{% if form.xhr_submit %}data-xhr-enabled="true"{% endif %}
{% if form.keep_alive %}data-grav-keepalive="true"{% endif %}
{% if form.attributes is defined %}
{% for key,attribute in form.attributes %}
{% if attribute|of_type('array') %}
{{ attribute.name }}="{{ attribute.value|e('html_attr') }}"
{% else %}
{{ key }}="{{ attribute|e('html_attr') }}"
{% endif %}
{% endfor %}
{% endif %}
{% endblock %}
{% block embed_form_classes -%}
class="{{ parent() }} {{ override_form_classes|trim }}"
{%- endblock %}
{% block embed_form_custom_attributes %}
{% for k, v in blueprints.form.attributes %}
{{ k }}="{{ v|e }}"
{% endfor %}
{% endblock %}
{% block embed_fields %}
{{ override_inner_markup_fields_start|raw }}
{{ override_inner_markup_fields|raw }}
{% if form.isEnabled() ?? true %}
{% include include_form_field('formname', field_layout, 'hidden') %}
{% include include_form_field('formtask', field_layout, 'hidden') %}
{% include include_form_field('uniqueid', field_layout, 'hidden') %}
{% include include_form_field('nonce', field_layout, 'hidden') %}
{% endif %}
{{ override_inner_markup_fields_end|raw }}
{% endblock %}
{% block embed_buttons %}
{{ override_inner_markup_buttons_start|raw }}
{% if form.isEnabled() ?? true %}
{% for button in form.buttons %}
{% if not button.access or authorize(button.access) %}
{% if button.outerclasses is defined %}<div class="{{ button.outerclasses }}">{% endif %}
{% if button.url %}
{% set button_url = button.url starts with 'http' ? button.url : base_url ~ button.url %}
{% endif %}
{% embed 'forms/layouts/button.html.twig' %}
{% block embed_button_core %}
{% if button.id %}id="{{ button.id }}"{% endif %}
{% if button.disabled %}disabled="disabled"{% endif %}
{% if button.name %}
name="{{ button.name }}"
{% else %}
{% if button.task %}name="task" value="{{ button.task }}"{% endif %}
{% endif %}
type="{{ button.type|default('submit') }}"
{% if button.attributes is defined %}
{% for key,attribute in button.attributes %}
{% if attribute|of_type('array') %}
{{ attribute.name }}="{{ attribute.value|e('html_attr') }}"
{% else %}
{{ key }}="{{ attribute|e('html_attr') }}"
{% endif %}
{% endfor %}
{% endif %}
{% endblock %}
{% block embed_button_classes %}
{% block button_classes %}
class="{{ form_button_classes ?: 'button' }} {{ button.classes }}"
{% endblock %}
{% endblock %}
{% block embed_button_content -%}
{%- set button_value = button.value|t|default('Submit') -%}
{%- if button.html -%}
{{- button_value|trim|raw -}}
{%- else -%}
{{- button_value|trim|e -}}
{%- endif -%}
{%- endblock %}
{% endembed %}
{% if button.outerclasses is defined %}</div>{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{{ override_inner_markup_buttons_end }}
{% endblock %}
{% endembed %}
{% if config.forms.dropzone.enabled %}
<div id="dropzone-template" style="display:none;">
{% include 'forms/dropzone/template.html.twig' %}
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,9 @@
<span class="checkboxes toggleable" data-grav-field="toggleable" data-grav-field-name="{{ field_name }}">
<input type="checkbox"
id="toggleable_{{ field.name }}"
name="toggleable_{{ field_name }}"
value="1"
{% if checked %}checked="checked"{% endif %}
>
<label for="toggleable_{{ field.name }}"></label>
</span>

View File

@@ -0,0 +1,39 @@
<div class="dz-preview dz-file-preview">
<div class="dz-image"><img data-dz-thumbnail /></div>
<div class="dz-details">
<div class="dz-size"><span data-dz-size></span></div>
<div class="dz-filename"><span data-dz-name></span></div>
</div>
<div class="dz-progress"><span class="dz-upload" data-dz-uploadprogress></span></div>
<div class="dz-error-message"><span data-dz-errormessage></span></div>
<a class="dz-remove" title="remove" href="javascript:undefined;" data-dz-remove>&times;</a>
<div class="dz-success-mark">
<svg width="54px" height="54px" viewBox="0 0 54 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: Sketch 3.2.1 (9971) - http://www.bohemiancoding.com/sketch -->
<title>Check</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<path d="M23.5,31.8431458 L17.5852419,25.9283877 C16.0248253,24.3679711 13.4910294,24.366835 11.9289322,25.9289322 C10.3700136,27.4878508 10.3665912,30.0234455 11.9283877,31.5852419 L20.4147581,40.0716123 C20.5133999,40.1702541 20.6159315,40.2626649 20.7218615,40.3488435 C22.2835669,41.8725651 24.794234,41.8626202 26.3461564,40.3106978 L43.3106978,23.3461564 C44.8771021,21.7797521 44.8758057,19.2483887 43.3137085,17.6862915 C41.7547899,16.1273729 39.2176035,16.1255422 37.6538436,17.6893022 L23.5,31.8431458 Z M27,53 C41.3594035,53 53,41.3594035 53,27 C53,12.6405965 41.3594035,1 27,1 C12.6405965,1 1,12.6405965 1,27 C1,41.3594035 12.6405965,53 27,53 Z" id="Oval-2" stroke-opacity="0.198794158" stroke="#747474" fill-opacity="0.816519475" fill="#FFFFFF" sketch:type="MSShapeGroup"></path>
</g>
</svg>
</div>
<div class="dz-error-mark">
<svg width="54px" height="54px" viewBox="0 0 54 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: Sketch 3.2.1 (9971) - http://www.bohemiancoding.com/sketch -->
<title>error</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="Check-+-Oval-2" sketch:type="MSLayerGroup" stroke="#747474" stroke-opacity="0.198794158" fill="#FFFFFF" fill-opacity="0.816519475">
<path d="M32.6568542,29 L38.3106978,23.3461564 C39.8771021,21.7797521 39.8758057,19.2483887 38.3137085,17.6862915 C36.7547899,16.1273729 34.2176035,16.1255422 32.6538436,17.6893022 L27,23.3431458 L21.3461564,17.6893022 C19.7823965,16.1255422 17.2452101,16.1273729 15.6862915,17.6862915 C14.1241943,19.2483887 14.1228979,21.7797521 15.6893022,23.3461564 L21.3431458,29 L15.6893022,34.6538436 C14.1228979,36.2202479 14.1241943,38.7516113 15.6862915,40.3137085 C17.2452101,41.8726271 19.7823965,41.8744578 21.3461564,40.3106978 L27,34.6568542 L32.6538436,40.3106978 C34.2176035,41.8744578 36.7547899,41.8726271 38.3137085,40.3137085 C39.8758057,38.7516113 39.8771021,36.2202479 38.3106978,34.6538436 L32.6568542,29 Z M27,53 C41.3594035,53 53,41.3594035 53,27 C53,12.6405965 41.3594035,1 27,1 C12.6405965,1 1,12.6405965 1,27 C1,41.3594035 12.6405965,53 27,53 Z" id="Oval-2" sketch:type="MSShapeGroup"></path>
</g>
</g>
</svg>
</div>
</div>

View File

@@ -0,0 +1,9 @@
{#
DO NOT MODIFY!
Default layout can be found in form plugin or your theme:
templates/forms/layouts/field/default-field.html.twig
#}
{% extends "forms/default/field.html.twig" %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -0,0 +1,6 @@
{% extends "forms/field.html.twig" %}
{% block input_attributes %}
type="color"
{{ parent() }}
{% endblock %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -0,0 +1 @@
{{ value ? value|date('m/d/Y')|e }}

View File

@@ -0,0 +1,2 @@
{# DEPRECATED. Switched to Text field until implemented properly #}
{% extends "forms/fields/text/text.html.twig" %}

View File

@@ -0,0 +1 @@
{{ value ? value|date('m/d/Y \\a\\t g:i A')|e }}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -0,0 +1,5 @@
{% extends "forms/field.html.twig" %}
{% block field %}
<input type="hidden" name="__form-name__" value="{{ form.name }}" />
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "forms/field.html.twig" %}
{% block field %}
{% if form.task %}
<input type="hidden" name="task" value="{{ form.task }}" />
{% endif %}
{% endblock %}

View File

@@ -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 %}

Some files were not shown because too many files have changed in this diff Show More