mirror of https://github.com/docusealco/docuseal
commit
6a644c85fd
@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RevealAccessTokenController < ApplicationController
|
||||
def show
|
||||
authorize!(:manage, current_user.access_token)
|
||||
end
|
||||
|
||||
def create
|
||||
authorize!(:manage, current_user.access_token)
|
||||
|
||||
if current_user.valid_password?(params[:password])
|
||||
render turbo_stream: turbo_stream.replace(:access_token_container,
|
||||
partial: 'reveal_access_token/access_token',
|
||||
locals: { token: current_user.access_token.token })
|
||||
else
|
||||
render turbo_stream: turbo_stream.replace(:modal, template: 'reveal_access_token/show',
|
||||
locals: { error_message: I18n.t('wrong_password') }),
|
||||
status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SearchEntriesReindexController < ApplicationController
|
||||
def create
|
||||
authorize!(:manage, EncryptedConfig)
|
||||
|
||||
ReindexAllSearchEntriesJob.perform_async
|
||||
|
||||
AccountConfig.find_or_initialize_by(account_id: Account.minimum(:id), key: :fulltext_search)
|
||||
.update!(value: true)
|
||||
|
||||
Docuseal.instance_variable_set(:@fulltext_search, nil)
|
||||
|
||||
redirect_back(fallback_location: settings_account_path,
|
||||
notice: "Started building search index. Visit #{root_url}jobs/busy to check progress.")
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class StartFormEmail2faSendController < ApplicationController
|
||||
around_action :with_browser_locale
|
||||
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def create
|
||||
@template = Template.find_by!(slug: params[:slug])
|
||||
|
||||
@submitter = @template.submissions.new(account_id: @template.account_id)
|
||||
.submitters.new(**submitter_params, account_id: @template.account_id)
|
||||
|
||||
Submitters.send_shared_link_email_verification_code(@submitter, request:)
|
||||
|
||||
redir_params = { notice: I18n.t(:code_has_been_resent) } if params[:resend]
|
||||
|
||||
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)),
|
||||
**redir_params
|
||||
rescue Submitters::UnableToSendCode => e
|
||||
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)),
|
||||
alert: e.message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def submitter_params
|
||||
params.require(:submitter).permit(:name, :email, :phone)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesCloneAndReplaceController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
return head :unprocessable_content if params[:files].blank?
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [schema_documents: :preview_images_attachments]
|
||||
).call
|
||||
|
||||
cloned_template = Templates::Clone.call(@template, author: current_user)
|
||||
cloned_template.name = File.basename(params[:files].first.original_filename, '.*')
|
||||
cloned_template.save!
|
||||
|
||||
documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true)
|
||||
|
||||
Templates.maybe_assign_access(cloned_template)
|
||||
|
||||
cloned_template.save!
|
||||
|
||||
Templates::CloneAttachments.call(template: cloned_template, original_template: @template,
|
||||
excluded_attachment_uuids: documents.map(&:uuid))
|
||||
|
||||
SearchEntries.enqueue_reindex(cloned_template)
|
||||
|
||||
respond_to do |f|
|
||||
f.html { redirect_to edit_template_path(cloned_template) }
|
||||
f.json { render json: { id: cloned_template.id } }
|
||||
end
|
||||
rescue Templates::CreateAttachments::PdfEncrypted
|
||||
respond_to do |f|
|
||||
f.html { render turbo_stream: turbo_stream.append(params[:form_id], html: helpers.tag.prompt_password) }
|
||||
f.json { render json: { error: 'PDF encrypted', status: 'pdf_encrypted' }, status: :unprocessable_content }
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesDetectFieldsController < ApplicationController
|
||||
include ActionController::Live
|
||||
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
response.headers['Content-Type'] = 'text/event-stream'
|
||||
|
||||
sse = SSE.new(response.stream)
|
||||
|
||||
documents = @template.schema_documents.preload(:blob)
|
||||
|
||||
documents.each do |document|
|
||||
io = StringIO.new(document.download)
|
||||
|
||||
Templates::DetectFields.call(io, attachment: document) do |(attachment_uuid, page, fields)|
|
||||
sse.write({ attachment_uuid:, page:, fields: })
|
||||
end
|
||||
end
|
||||
|
||||
sse.write({ completed: true })
|
||||
ensure
|
||||
response.stream.close
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesPrefillableFieldsController < ApplicationController
|
||||
PREFILLABLE_FIELD_TYPES = %w[text number cells date checkbox select radio phone].freeze
|
||||
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
authorize!(:update, @template)
|
||||
|
||||
field = @template.fields.find { |f| f['uuid'] == params[:field_uuid] }
|
||||
|
||||
if params[:prefillable] == 'false'
|
||||
field.delete('prefillable')
|
||||
field.delete('readonly')
|
||||
elsif params[:prefillable] == 'true'
|
||||
field['prefillable'] = true
|
||||
field['readonly'] = true
|
||||
end
|
||||
|
||||
@template.save!
|
||||
|
||||
render turbo_stream: turbo_stream.replace(:prefillable_fields_list, partial: 'list',
|
||||
locals: { template: @template })
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesShareLinkController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
authorize!(:update, @template)
|
||||
|
||||
@template.update!(template_params)
|
||||
|
||||
if params[:redir].present?
|
||||
redirect_to params[:redir]
|
||||
else
|
||||
head :ok
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(:shared_link)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UsersSendResetPasswordController < ApplicationController
|
||||
load_and_authorize_resource :user
|
||||
|
||||
LIMIT_DURATION = 10.minutes
|
||||
|
||||
def update
|
||||
authorize!(:manage, @user)
|
||||
|
||||
if @user.reset_password_sent_at && @user.reset_password_sent_at > LIMIT_DURATION.ago
|
||||
redirect_back fallback_location: settings_users_path, notice: I18n.t('email_has_been_sent_already')
|
||||
else
|
||||
@user.send_reset_password_instructions
|
||||
|
||||
redirect_back fallback_location: settings_users_path,
|
||||
notice: I18n.t('an_email_with_password_reset_instructions_has_been_sent')
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class WebhookEventsController < ApplicationController
|
||||
load_and_authorize_resource :webhook_url, parent: false, id_param: :webhook_id
|
||||
before_action :load_webhook_event
|
||||
|
||||
def show
|
||||
return unless current_ability.can?(:read, @webhook_event.record)
|
||||
|
||||
@data =
|
||||
case @webhook_event.event_type
|
||||
when 'form.started', 'form.completed', 'form.declined', 'form.viewed'
|
||||
Submitters::SerializeForWebhook.call(@webhook_event.record)
|
||||
when 'submission.created', 'submission.completed', 'submission.expired'
|
||||
Submissions::SerializeForApi.call(@webhook_event.record)
|
||||
when 'template.created', 'template.updated'
|
||||
Templates::SerializeForApi.call(@webhook_event.record)
|
||||
when 'submission.archived'
|
||||
@webhook_event.record.as_json(only: %i[id archived_at])
|
||||
end
|
||||
end
|
||||
|
||||
def resend
|
||||
id_key = WebhookUrls::EVENT_TYPE_ID_KEYS.fetch(@webhook_event.event_type.split('.').first)
|
||||
|
||||
last_attempt_id = @webhook_event.webhook_attempts.maximum(:id)
|
||||
|
||||
WebhookUrls::EVENT_TYPE_TO_JOB_CLASS[@webhook_event.event_type].perform_async(
|
||||
id_key => @webhook_event.record_id,
|
||||
'webhook_url_id' => @webhook_event.webhook_url_id,
|
||||
'event_uuid' => @webhook_event.uuid,
|
||||
'attempt' => SendWebhookRequest::MANUAL_ATTEMPT,
|
||||
'last_status' => 0
|
||||
)
|
||||
|
||||
render turbo_stream: [
|
||||
turbo_stream.after(
|
||||
params[:button_id],
|
||||
helpers.tag.submit_form(
|
||||
helpers.button_to('', refresh_settings_webhook_event_path(@webhook_url.id, @webhook_event.uuid),
|
||||
params: { last_attempt_id: }),
|
||||
class: 'hidden', data: { interval: 3_000 }
|
||||
)
|
||||
)
|
||||
]
|
||||
end
|
||||
|
||||
def refresh
|
||||
return head :ok if @webhook_event.webhook_attempts.maximum(:id) == params[:last_attempt_id].to_i
|
||||
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(helpers.dom_id(@webhook_event),
|
||||
partial: 'event_row',
|
||||
locals: { with_status: true, webhook_url: @webhook_url, webhook_event: @webhook_event }),
|
||||
turbo_stream.replace(helpers.dom_id(@webhook_event, :drawer_events),
|
||||
partial: 'drawer_events',
|
||||
locals: { webhook_url: @webhook_url, webhook_event: @webhook_event })
|
||||
]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_webhook_event
|
||||
@webhook_event = @webhook_url.webhook_events.find_by!(uuid: params[:id])
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,338 @@
|
||||
export default class extends HTMLElement {
|
||||
async connectedCallback () {
|
||||
this.tourType = this.dataset.type
|
||||
this.nextPagePath = this.dataset.nextPagePath
|
||||
this.I18n = JSON.parse(this.dataset.i18n || '{}')
|
||||
|
||||
if (this.dataset.showTour === 'true') this.start()
|
||||
}
|
||||
|
||||
async start () {
|
||||
if (window.innerWidth < 768) return
|
||||
|
||||
const [{ driver }] = await Promise.all([
|
||||
import('driver.js'),
|
||||
import('driver.js/dist/driver.css')
|
||||
])
|
||||
|
||||
this.driverObj = driver({
|
||||
showProgress: true,
|
||||
nextBtnText: this.I18n.next,
|
||||
prevBtnText: this.I18n.previous,
|
||||
doneBtnText: this.I18n.done,
|
||||
onDestroyStarted: () => {
|
||||
this.disableAppGuide().finally(() => { this.destroy() })
|
||||
},
|
||||
onHighlightStarted: (element) => {
|
||||
if (element) {
|
||||
const clickHandler = () => {
|
||||
this.disableAppGuide().finally(() => { this.destroy() })
|
||||
element.removeEventListener('click', clickHandler)
|
||||
}
|
||||
|
||||
element.addEventListener('click', clickHandler)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (this.tourType === 'dashboard') {
|
||||
this.showDashboardTour()
|
||||
} else if (this.tourType === 'builder') {
|
||||
this.showTemplateBuilderTour()
|
||||
} else if (this.tourType === 'account') {
|
||||
this.showAccountTour()
|
||||
} else if (this.tourType === 'template') {
|
||||
this.showTemplateTour()
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
if (this.driverObj) this.destroy()
|
||||
}
|
||||
|
||||
destroy () {
|
||||
if (this.builderTemplate) this.builderTemplate.fields.shift()
|
||||
if (this.driverObj) this.driverObj.destroy()
|
||||
}
|
||||
|
||||
showTemplateTour () {
|
||||
const steps = [
|
||||
{
|
||||
element: '#share_link_clipboard',
|
||||
popover: {
|
||||
title: this.I18n.copy_and_share_link,
|
||||
description: this.I18n.copy_and_share_link_description,
|
||||
side: 'bottom',
|
||||
align: 'end'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#sign_yourself_button',
|
||||
popover: {
|
||||
title: this.I18n.sign_the_document,
|
||||
description: this.I18n.sign_the_document_description,
|
||||
side: 'top',
|
||||
align: 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#send_to_recipients_button',
|
||||
popover: {
|
||||
title: this.I18n.send_for_signing,
|
||||
description: this.I18n.add_recipients_description,
|
||||
side: 'top',
|
||||
align: 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#add_recipients_button',
|
||||
popover: {
|
||||
title: this.I18n.add_recipients,
|
||||
description: this.I18n.add_recipients_description,
|
||||
side: 'bottom',
|
||||
align: 'end'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#account_settings_button',
|
||||
popover: {
|
||||
title: this.I18n.settings,
|
||||
description: this.I18n.settings_template_description,
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
showButtons: this.nextPagePath ? ['next', 'previous', 'close'] : ['previous', 'close'],
|
||||
onNextClick: () => {
|
||||
if (this.nextPagePath) {
|
||||
window.Turbo.visit(this.nextPagePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
].filter((step) => document.querySelector(step.element))
|
||||
|
||||
this.driverObj.setSteps(steps)
|
||||
this.driverObj.drive()
|
||||
}
|
||||
|
||||
showDashboardTour () {
|
||||
this.driverObj.setSteps([
|
||||
{
|
||||
element: '#templates_submissions_toggle',
|
||||
popover: {
|
||||
title: this.I18n.template_and_submissions,
|
||||
description: this.I18n.template_and_submissions_description,
|
||||
side: 'right',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#templates_upload_button',
|
||||
popover: {
|
||||
title: this.I18n.upload_a_pdf_file,
|
||||
description: this.I18n.upload_a_pdf_file_description,
|
||||
side: 'left',
|
||||
align: 'start',
|
||||
showButtons: this.nextPagePath ? ['next', 'previous', 'close'] : ['previous', 'close'],
|
||||
onNextClick: () => {
|
||||
if (this.nextPagePath) {
|
||||
window.Turbo.visit(this.nextPagePath)
|
||||
}
|
||||
}
|
||||
},
|
||||
onHighlightStarted: () => {}
|
||||
}
|
||||
])
|
||||
|
||||
this.driverObj.drive()
|
||||
}
|
||||
|
||||
showAccountTour () {
|
||||
this.driverObj.setSteps([
|
||||
{
|
||||
element: '#account_settings_menu',
|
||||
popover: {
|
||||
title: this.I18n.settings,
|
||||
description: this.I18n.settings_account_description,
|
||||
side: 'right',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#support_channels',
|
||||
popover: {
|
||||
title: this.I18n.support,
|
||||
description: this.I18n.support_description,
|
||||
side: 'left',
|
||||
align: 'start'
|
||||
}
|
||||
}
|
||||
].filter((step) => document.querySelector(step.element)))
|
||||
|
||||
this.driverObj.drive()
|
||||
}
|
||||
|
||||
showTemplateBuilderTour () {
|
||||
const builderComponent = document.querySelector('template-builder')?.component
|
||||
|
||||
this.builderTemplate = builderComponent?.template
|
||||
|
||||
if (this.builderTemplate) {
|
||||
this.builderTemplate.fields.unshift({
|
||||
uuid: 'b387399b-88dc-4345-9d37-743e97a9b2b3',
|
||||
submitter_uuid: this.builderTemplate.submitters[0].uuid,
|
||||
name: 'First Name',
|
||||
type: 'text'
|
||||
})
|
||||
|
||||
builderComponent.$nextTick(() => {
|
||||
this.driverObj.setSteps([
|
||||
{
|
||||
element: '.roles-dropdown',
|
||||
popover: {
|
||||
title: this.I18n.select_a_signer_party,
|
||||
description: this.I18n.select_a_signer_party_description,
|
||||
side: 'left',
|
||||
align: 'start',
|
||||
onPopoverRender: () => {
|
||||
const rolesDropdown = document.querySelector('.roles-dropdown')
|
||||
|
||||
rolesDropdown.dispatchEvent(new Event('mouseenter', { bubbles: true, cancelable: true }))
|
||||
rolesDropdown.classList.add('dropdown-open')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.roles-dropdown .dropdown-content',
|
||||
popover: {
|
||||
title: this.I18n.available_parties,
|
||||
description: this.I18n.available_parties_description,
|
||||
side: 'left',
|
||||
align: 'start',
|
||||
onPopoverRender: () => {
|
||||
document.querySelector('.roles-dropdown .dropdown-content').classList.remove('driver-active-element')
|
||||
},
|
||||
onNextClick: () => {
|
||||
document.querySelector('.roles-dropdown').classList.remove('dropdown-open')
|
||||
this.driverObj.moveNext()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#field-types-grid',
|
||||
popover: {
|
||||
title: this.I18n.available_field_types,
|
||||
description: this.I18n.available_field_types_description,
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
onPrevClick: () => {
|
||||
document.querySelector('.roles-dropdown').classList.add('dropdown-open')
|
||||
this.driverObj.movePrevious()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#text_type_field_button',
|
||||
popover: {
|
||||
title: this.I18n.text_input_field,
|
||||
description: this.I18n.text_input_field_description,
|
||||
side: 'left',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#signature_type_field_button',
|
||||
popover: {
|
||||
title: this.I18n.signature_field,
|
||||
description: this.I18n.signature_field_description,
|
||||
side: 'left',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.fields',
|
||||
popover: {
|
||||
title: this.I18n.added_fields,
|
||||
description: this.I18n.added_fields_description,
|
||||
side: 'right',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.list-field label:has(svg.tabler-icon-settings)',
|
||||
popover: {
|
||||
title: this.I18n.open_field_settings,
|
||||
description: this.I18n.open_field_settings_description,
|
||||
side: 'bottom',
|
||||
align: 'end',
|
||||
onPopoverRender: () => {
|
||||
const settingsDropdown = document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings)')
|
||||
|
||||
document.querySelectorAll('.list-field div:first-child .text-transparent').forEach((e) => e.classList.remove('text-transparent'))
|
||||
settingsDropdown.dispatchEvent(new Event('mouseenter', { bubbles: true, cancelable: true }))
|
||||
settingsDropdown.classList.add('dropdown-open')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.list-field div:first-child span:has(svg.tabler-icon-settings) .dropdown-content',
|
||||
popover: {
|
||||
title: this.I18n.field_settings,
|
||||
description: this.I18n.field_settings_description,
|
||||
side: 'left',
|
||||
align: 'start',
|
||||
onPopoverRender: () => {
|
||||
document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings) .dropdown-content').classList.remove('driver-active-element')
|
||||
},
|
||||
onNextClick: () => {
|
||||
document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings)').classList.remove('dropdown-open')
|
||||
this.driverObj.moveNext()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#send_button',
|
||||
popover: {
|
||||
title: this.I18n.send_document,
|
||||
description: this.I18n.send_document_description,
|
||||
side: 'bottom',
|
||||
align: 'end',
|
||||
onPrevClick: () => {
|
||||
document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings)').classList.add('dropdown-open')
|
||||
this.driverObj.movePrevious()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#sign_yourself_button',
|
||||
popover: {
|
||||
title: this.I18n.sign_yourself,
|
||||
description: this.I18n.sign_yourself_description,
|
||||
side: 'bottom',
|
||||
align: 'end',
|
||||
onNextClick: () => {
|
||||
if (this.nextPagePath) {
|
||||
window.Turbo.visit(this.nextPagePath)
|
||||
} else {
|
||||
this.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
this.driverObj.drive()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async disableAppGuide () {
|
||||
return fetch('/user_configs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({ key: 'show_app_tour', value: false })
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.querySelector('form').addEventListener('submit', () => {
|
||||
window.app_tour.start()
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const originalFontValue = this.field.style.fontSize
|
||||
|
||||
if (this.field.scrollHeight > this.field.clientHeight) {
|
||||
this.field.style.fontSize = `calc(${originalFontValue} / 1.5)`
|
||||
this.field.style.lineHeight = `calc(${this.field.style.fontSize} * 1.3)`
|
||||
|
||||
if (this.field.scrollHeight > this.field.clientHeight) {
|
||||
this.field.style.fontSize = `calc(${originalFontValue} / 2.0)`
|
||||
this.field.style.lineHeight = `calc(${this.field.style.fontSize} * 1.3)`
|
||||
}
|
||||
}
|
||||
|
||||
this.field.classList.remove('hidden')
|
||||
}
|
||||
|
||||
get field () {
|
||||
return this.closest('field-value')
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('click', () => {
|
||||
if (this.element && !this.element.disabled && !this.element.checked) {
|
||||
this.element.checked = true
|
||||
this.element.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
get element () {
|
||||
return document.getElementById(this.dataset.elementId)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const input = this.querySelector('input')
|
||||
const invalidMessage = this.dataset.invalidMessage || ''
|
||||
|
||||
input.addEventListener('invalid', () => {
|
||||
input.setCustomValidity(input.value ? invalidMessage : '')
|
||||
})
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
input.setCustomValidity('')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,248 @@
|
||||
import { target, targets, targetable } from '@github/catalyst/lib/targetable'
|
||||
|
||||
const loadingIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 3a9 9 0 1 0 9 9" />
|
||||
</svg>`
|
||||
|
||||
export default targetable(class extends HTMLElement {
|
||||
static [targets.static] = [
|
||||
'hiddenOnDrag',
|
||||
'folderCards',
|
||||
'templateCards'
|
||||
]
|
||||
|
||||
static [target.static] = [
|
||||
'form',
|
||||
'fileDropzone',
|
||||
'folderDropzone',
|
||||
'fileDropzoneLoading'
|
||||
]
|
||||
|
||||
connectedCallback () {
|
||||
document.addEventListener('drop', this.onWindowDragdrop)
|
||||
document.addEventListener('dragover', this.onWindowDropover)
|
||||
|
||||
window.addEventListener('dragleave', this.onWindowDragleave)
|
||||
|
||||
this.fileDropzone?.addEventListener('drop', this.onDropFile)
|
||||
this.folderDropzone?.addEventListener('drop', this.onDropNewFolder)
|
||||
|
||||
this.folderCards.forEach((el) => el.addEventListener('drop', (e) => this.onDropFolder(e, el)))
|
||||
this.templateCards.forEach((el) => el.addEventListener('drop', this.onDropTemplate))
|
||||
this.templateCards.forEach((el) => el.addEventListener('dragstart', this.onTemplateDragStart))
|
||||
|
||||
return [this.fileDropzone, this.folderDropzone, ...this.folderCards, ...this.templateCards].forEach((el) => {
|
||||
el?.addEventListener('dragover', this.onDragover)
|
||||
el?.addEventListener('dragleave', this.onDragleave)
|
||||
})
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('drop', this.onWindowDragdrop)
|
||||
document.removeEventListener('dragover', this.onWindowDropover)
|
||||
|
||||
window.removeEventListener('dragleave', this.onWindowDragleave)
|
||||
}
|
||||
|
||||
onTemplateDragStart = (e) => {
|
||||
const id = e.target.href.split('/').pop()
|
||||
|
||||
this.folderCards.forEach((el) => el.classList.remove('bg-base-200', 'before:hidden'))
|
||||
this.folderDropzone?.classList?.remove('hidden')
|
||||
window.flash?.remove()
|
||||
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
|
||||
if (id) {
|
||||
e.dataTransfer.setData('template_id', id)
|
||||
|
||||
const dragPreview = e.target.cloneNode(true)
|
||||
const rect = e.target.getBoundingClientRect()
|
||||
|
||||
const height = e.target.children[0].getBoundingClientRect().height + 50
|
||||
|
||||
dragPreview.children[1].remove()
|
||||
dragPreview.style.width = `${rect.width}px`
|
||||
dragPreview.style.height = `${height}px`
|
||||
dragPreview.style.position = 'absolute'
|
||||
dragPreview.style.top = '-1000px'
|
||||
dragPreview.style.pointerEvents = 'none'
|
||||
dragPreview.style.opacity = '0.9'
|
||||
|
||||
document.body.appendChild(dragPreview)
|
||||
|
||||
e.dataTransfer.setDragImage(dragPreview, rect.width / 2, height / 2)
|
||||
|
||||
setTimeout(() => document.body.removeChild(dragPreview), 0)
|
||||
}
|
||||
}
|
||||
|
||||
onDropFile = (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
this.fileDropzoneLoading.classList.remove('hidden')
|
||||
this.fileDropzoneLoading.previousElementSibling.classList.add('hidden')
|
||||
this.fileDropzoneLoading.classList.add('opacity-50')
|
||||
|
||||
this.uploadFiles(e.dataTransfer.files, '/templates_upload')
|
||||
}
|
||||
|
||||
onDropFolder = (e, el) => {
|
||||
e.preventDefault()
|
||||
|
||||
const templateId = e.dataTransfer.getData('template_id')
|
||||
|
||||
if (e.dataTransfer.files.length || templateId) {
|
||||
const loading = document.createElement('div')
|
||||
const svg = el.querySelector('svg')
|
||||
|
||||
loading.innerHTML = loadingIconHtml
|
||||
loading.children[0].classList.add(...svg.classList)
|
||||
|
||||
el.replaceChild(loading.children[0], svg)
|
||||
el.classList.add('opacity-50')
|
||||
|
||||
if (e.dataTransfer.files.length) {
|
||||
const params = new URLSearchParams({ folder_name: el.innerText.trim() }).toString()
|
||||
|
||||
this.uploadFiles(e.dataTransfer.files, `/templates_upload?${params}`)
|
||||
} else {
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('name', el.dataset.fullName)
|
||||
|
||||
fetch(`/templates/${templateId}/folder`, {
|
||||
method: 'PUT',
|
||||
redirect: 'manual',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
}
|
||||
}).finally(() => {
|
||||
window.Turbo.cache.clear()
|
||||
window.Turbo.visit(location.href)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDropTemplate = (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (e.dataTransfer.files.length) {
|
||||
const loading = document.createElement('div')
|
||||
loading.classList.add('bottom-5', 'left-0', 'flex', 'justify-center', 'w-full', 'absolute')
|
||||
loading.innerHTML = loadingIconHtml
|
||||
|
||||
e.target.appendChild(loading)
|
||||
e.target.classList.add('opacity-50')
|
||||
|
||||
const id = e.target.href.split('/').pop()
|
||||
|
||||
this.uploadFiles(e.dataTransfer.files, `/templates/${id}/clone_and_replace`)
|
||||
}
|
||||
}
|
||||
|
||||
onWindowDragdrop = (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!this.isLoading) this.hideDraghover()
|
||||
}
|
||||
|
||||
uploadFiles (files, url) {
|
||||
this.isLoading = true
|
||||
|
||||
this.form.action = url
|
||||
|
||||
this.form.querySelector('[type="file"]').files = files
|
||||
|
||||
this.form.querySelector('[type="submit"]').click()
|
||||
}
|
||||
|
||||
onWindowDropover = (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (e.dataTransfer?.types?.includes('Files')) {
|
||||
this.showDraghover()
|
||||
}
|
||||
}
|
||||
|
||||
onDragover (e) {
|
||||
if (e.dataTransfer?.types?.includes('Files') || this.dataset.targets !== 'dashboard-dropzone.templateCards') {
|
||||
this.style.backgroundColor = '#F7F3F0'
|
||||
|
||||
if (this.classList.contains('before:border-base-300')) {
|
||||
this.classList.remove('before:border-base-300')
|
||||
this.classList.add('before:border-base-content/30')
|
||||
} else if (this.classList.contains('border-base-300')) {
|
||||
this.classList.remove('border-base-300')
|
||||
this.classList.add('border-base-content/30')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDropNewFolder (e) {
|
||||
e.preventDefault()
|
||||
|
||||
const templateId = e.dataTransfer.getData('template_id')
|
||||
|
||||
const a = document.createElement('a')
|
||||
|
||||
a.href = `/templates/${templateId}/folder/edit?autocomplete=false`
|
||||
a.dataset.turboFrame = 'modal'
|
||||
a.classList.add('hidden')
|
||||
|
||||
document.body.append(a)
|
||||
|
||||
a.click()
|
||||
|
||||
a.remove()
|
||||
}
|
||||
|
||||
onDragleave () {
|
||||
this.style.backgroundColor = null
|
||||
|
||||
if (this.classList.contains('before:border-base-content/30')) {
|
||||
this.classList.remove('before:border-base-content/30')
|
||||
this.classList.add('before:border-base-300')
|
||||
} else if (this.classList.contains('border-base-content/30')) {
|
||||
this.classList.remove('border-base-content/30')
|
||||
this.classList.add('border-base-300')
|
||||
}
|
||||
}
|
||||
|
||||
onWindowDragleave = (e) => {
|
||||
if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) {
|
||||
this.hideDraghover()
|
||||
}
|
||||
}
|
||||
|
||||
showDraghover = () => {
|
||||
if (this.isDrag) return
|
||||
|
||||
this.isDrag = true
|
||||
|
||||
window.flash?.remove()
|
||||
this.fileDropzone?.classList?.remove('hidden')
|
||||
|
||||
this.hiddenOnDrag.forEach((el) => { el.style.display = 'none' })
|
||||
|
||||
return [...this.folderCards, ...this.templateCards].forEach((el) => {
|
||||
el.classList.remove('bg-base-200', 'before:hidden')
|
||||
})
|
||||
}
|
||||
|
||||
hideDraghover = () => {
|
||||
this.isDrag = false
|
||||
|
||||
this.fileDropzone?.classList?.add('hidden')
|
||||
this.folderDropzone?.classList?.add('hidden')
|
||||
|
||||
this.hiddenOnDrag.forEach((el) => { el.style.display = null })
|
||||
|
||||
return [...this.folderCards, ...this.templateCards].forEach((el) => {
|
||||
el.classList.add('bg-base-200', 'before:hidden')
|
||||
})
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,131 @@
|
||||
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||
|
||||
let loaderPromise = null
|
||||
|
||||
function loadCodeMirror () {
|
||||
if (!loaderPromise) {
|
||||
loaderPromise = Promise.all([
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/view'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/language'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow')
|
||||
]).then(([view, commands, language, html, htmlflow]) => {
|
||||
return {
|
||||
minimalSetup: [
|
||||
commands.history(),
|
||||
language.syntaxHighlighting(language.defaultHighlightStyle, { fallback: true }),
|
||||
view.keymap.of([...commands.defaultKeymap, ...commands.historyKeymap])
|
||||
],
|
||||
EditorView: view.EditorView,
|
||||
html: html.html,
|
||||
htmlflow: htmlflow.default || htmlflow
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return loaderPromise
|
||||
}
|
||||
|
||||
export default targetable(class extends HTMLElement {
|
||||
static [target.static] = [
|
||||
'codeViewTab',
|
||||
'previewViewTab',
|
||||
'editorContainer',
|
||||
'previewIframe'
|
||||
]
|
||||
|
||||
connectedCallback () {
|
||||
this.mount()
|
||||
|
||||
if (this.input.value) {
|
||||
this.showPreviewView()
|
||||
} else {
|
||||
this.showCodeView()
|
||||
}
|
||||
|
||||
this.previewViewTab.addEventListener('click', this.showPreviewView)
|
||||
this.codeViewTab.addEventListener('click', this.showCodeView)
|
||||
}
|
||||
|
||||
showCodeView = () => {
|
||||
this.editorView.dispatch({
|
||||
changes: { from: 0, to: this.editorView.state.doc.length, insert: this.input.value }
|
||||
})
|
||||
|
||||
this.previewViewTab.classList.remove('tab-active', 'tab-bordered')
|
||||
this.previewViewTab.classList.add('pb-[3px]')
|
||||
this.codeViewTab.classList.remove('pb-[3px]')
|
||||
this.codeViewTab.classList.add('tab-active', 'tab-bordered')
|
||||
this.editorContainer.classList.remove('hidden')
|
||||
this.previewIframe.classList.add('hidden')
|
||||
}
|
||||
|
||||
showPreviewView = () => {
|
||||
this.previewIframe.srcdoc = this.input.value
|
||||
|
||||
this.codeViewTab.classList.remove('tab-active', 'tab-bordered')
|
||||
this.codeViewTab.classList.add('pb-[3px]')
|
||||
this.previewViewTab.classList.remove('pb-[3px]')
|
||||
this.previewViewTab.classList.add('tab-active', 'tab-bordered')
|
||||
this.editorContainer.classList.add('hidden')
|
||||
this.previewIframe.classList.remove('hidden')
|
||||
}
|
||||
|
||||
async mount () {
|
||||
this.input = this.querySelector('input[type="hidden"]')
|
||||
this.input.style.display = 'none'
|
||||
|
||||
const { EditorView, minimalSetup, html, htmlflow } = await loadCodeMirror()
|
||||
|
||||
this.editorView = new EditorView({
|
||||
doc: this.input.value,
|
||||
parent: this.editorContainer,
|
||||
extensions: [
|
||||
html(),
|
||||
minimalSetup,
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of(update => {
|
||||
if (update.docChanged) this.input.value = update.state.doc.toString()
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: 'white',
|
||||
color: 'black',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'monospace'
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none'
|
||||
},
|
||||
'&.cm-editor': {
|
||||
borderRadius: '0.375rem',
|
||||
border: 'none'
|
||||
},
|
||||
'.cm-gutters': {
|
||||
display: 'none'
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
this.previewIframe.srcdoc = this.editorView.state.doc.toString()
|
||||
|
||||
this.previewIframe.onload = () => {
|
||||
const previewIframeDoc = this.previewIframe.contentDocument
|
||||
|
||||
if (previewIframeDoc.body) {
|
||||
previewIframeDoc.body.contentEditable = true
|
||||
}
|
||||
|
||||
const contentDocument = this.previewIframe.contentDocument || this.previewIframe.contentWindow.document
|
||||
|
||||
contentDocument.body.addEventListener('input', async () => {
|
||||
const html = contentDocument.documentElement.outerHTML.replace(' contenteditable="true"', '')
|
||||
const prettifiedHtml = await htmlflow(html)
|
||||
|
||||
this.input.value = prettifiedHtml
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,36 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.form.addEventListener('submit', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
this.submit()
|
||||
})
|
||||
|
||||
if (this.dataset.onload === 'true') {
|
||||
this.form.querySelector('button').click()
|
||||
}
|
||||
}
|
||||
|
||||
submit () {
|
||||
fetch(this.form.action, {
|
||||
method: this.form.method,
|
||||
body: new FormData(this.form)
|
||||
}).then(async (resp) => {
|
||||
if (!resp.ok) {
|
||||
try {
|
||||
const data = JSON.parse(await resp.text())
|
||||
|
||||
if (data.error) {
|
||||
alert(data.error)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
get form () {
|
||||
return this.querySelector('form')
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const iframeTemplate = this.querySelector('template')
|
||||
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some(e => e.isIntersecting)) {
|
||||
iframeTemplate.parentElement.prepend(iframeTemplate.content)
|
||||
|
||||
this.observer.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
this.observer.observe(this)
|
||||
|
||||
window.addEventListener('message', this.messageHandler)
|
||||
}
|
||||
|
||||
messageHandler = (event) => {
|
||||
if (event.data.type === 'google-drive-files-picked') {
|
||||
this.form.querySelectorAll('input[name="google_drive_file_ids[]"]').forEach(el => el.remove())
|
||||
|
||||
const files = event.data.files || []
|
||||
|
||||
files.forEach((file) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'hidden'
|
||||
input.name = 'google_drive_file_ids[]'
|
||||
input.value = file.id
|
||||
this.form.appendChild(input)
|
||||
})
|
||||
|
||||
this.form.querySelector('button[type="submit"]').click()
|
||||
this.loader.classList.remove('hidden')
|
||||
} else if (event.data.type === 'google-drive-picker-loaded') {
|
||||
this.loader.classList.add('hidden')
|
||||
this.form.classList.remove('hidden')
|
||||
} else if (event.data.type === 'google-drive-picker-request-oauth') {
|
||||
document.getElementById(this.dataset.oauthButtonId).classList.remove('hidden')
|
||||
this.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.observer?.unobserve(this)
|
||||
window.removeEventListener('message', this.messageHandler)
|
||||
}
|
||||
|
||||
get form () {
|
||||
return this.querySelector('form')
|
||||
}
|
||||
|
||||
get loader () {
|
||||
return document.getElementById('google_drive_loader')
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('click', () => {
|
||||
document.body.append(this.template.content)
|
||||
})
|
||||
}
|
||||
|
||||
get template () {
|
||||
return document.getElementById(this.dataset.templateId)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const src = this.getAttribute('src')
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = src
|
||||
link.setAttribute('data-turbo-frame', 'modal')
|
||||
link.style.display = 'none'
|
||||
|
||||
this.appendChild(link)
|
||||
|
||||
link.click()
|
||||
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const image = this.querySelector('img')
|
||||
|
||||
image.addEventListener('load', (e) => {
|
||||
image.setAttribute('width', e.target.naturalWidth)
|
||||
image.setAttribute('height', e.target.naturalHeight)
|
||||
|
||||
this.style.aspectRatio = `${e.target.naturalWidth} / ${e.target.naturalHeight}`
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const eventType = this.dataset.on || 'click'
|
||||
const selector = document.getElementById(this.dataset.selectorId) || this
|
||||
const eventElement = eventType === 'submit' ? this.querySelector('form') : this
|
||||
|
||||
eventElement.addEventListener(eventType, (event) => {
|
||||
if (eventType === 'click') {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
selector.remove()
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue