diff --git a/Gemfile b/Gemfile index 5714b981..3677d738 100644 --- a/Gemfile +++ b/Gemfile @@ -79,6 +79,7 @@ gem 'webpacker' gem 'yaml_db', git: 'https://0xacab.org/sutty/yaml_db.git' gem 'kaminari' gem 'device_detector' +gem 'htmlbeautifier' gem 'dry-schema' gem 'rubanok' diff --git a/Gemfile.lock b/Gemfile.lock index 7f5284ef..2adf2f1b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -269,6 +269,7 @@ GEM hiredis (0.6.3-x86_64-linux-musl) hiredis-client (0.14.1-x86_64-linux-musl) redis-client (= 0.14.1) + htmlbeautifier (1.4.2) http_parser.rb (0.8.0-x86_64-linux-musl) httparty (0.21.0) mini_mime (>= 1.0.0) @@ -659,6 +660,7 @@ DEPENDENCIES hamlit-rails hiredis hiredis-client + htmlbeautifier httparty icalendar image_processing diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 4d1d0848..6efb5ab8 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -11,6 +11,21 @@ $colors: ( "magenta": $magenta ); +// TODO: Encontrar la forma de generar esto desde los locales de Rails +$custom-file-text: ( + en: "Browse", + es: "Buscar archivo", + pt: "Buscar ficheiro", + pt-BR: "Buscar arquivo" +); + +$custom-file-text-replace: ( + en: "Replace file", + es: "Reemplazar archivo", + pt: "substituir ficheiro", + pt-BR: "substituir arquivo" +); + // Redefinir variables de Bootstrap $primary: $magenta; $secondary: $black; @@ -20,6 +35,17 @@ $form-feedback-valid-color: $black; $form-feedback-invalid-color: $magenta; $form-feedback-icon-valid-color: $black; $component-active-bg: $magenta; +$zindex-modal-backdrop: 0; +$modal-content-bg: var(--background); +$modal-content-border-color: var(--modal-content-border-color); +$card-bg: var(--background); +$card-border-color: var(--card-border-color); +$input-bg: var(--background); +$input-color: var(--foreground); +$btn-bg-color: var(--btn-bg-color); +$btn-color: var(--btn-color); +$input-group-addon-bg: var(--btn-bg-color); +$custom-file-color: var(--btn-color); $spacers: ( 2-plus: 0.75rem @@ -32,6 +58,16 @@ $sizes: ( @import "bootstrap"; @import "editor"; +.custom-file-input { + &.replace-image { + @each $lang, $value in $custom-file-text-replace { + &:lang(#{$lang}) ~ .custom-file-label::after { + content: $value; + } + } + } +} + @each $color, $rgb in $theme-colors { .#{$color} { color: var(--#{$color}); @@ -60,6 +96,8 @@ $sizes: ( --foreground: #{$black}; --background: #{$white}; --color: #{$magenta}; + --card-border-color: #{rgba($black, .125)}; + --modal-content-border-color: rgba(#{$black}, .2); } @media (prefers-color-scheme: dark) { @@ -67,6 +105,8 @@ $sizes: ( --foreground: #{$white}; --background: #{$black}; --color: #{$cyan}; + --card-border-color: #{rgba($white, .125)}; + --modal-content-border-color: #{rgba($white, .2)}; } .btn-secondary { @@ -87,13 +127,17 @@ $sizes: ( box-shadow: 0 0 0 0.2rem $cyan; } } -} -// TODO: Encontrar la forma de generar esto desde los locales de Rails -$custom-file-text: ( - en: 'Browse', - es: 'Buscar archivo' -); + @include form-validation-state("valid", $cyan, url("data:image/svg+xml,")); + + .custom-checkbox { + .custom-control-input:checked ~ .custom-control-label { + &::after { + background-image: url("data:image/svg+xml,"); + } + } + } +} @font-face { font-family: 'Saira'; @@ -318,10 +362,6 @@ svg { } } -.custom-control-label { - font-weight: bold; -} - .designs { .design { margin-top: 1rem; @@ -621,3 +661,33 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1); } } } + +// https://getbootstrap.com/docs/5.1/components/placeholders/ +.placeholder { + display: inline-block; + min-height: $spacer; + cursor: wait; + vertical-align: middle; + opacity: .5; + background-color: $grey; + animation: placeholder-glow 2s ease-in-out infinite; +} + +.placeholder-glow { + .placeholder { + -webkit-animation: placeholder-glow 2s ease-in-out infinite; + animation: placeholder-glow 2s ease-in-out infinite; + } + + @-webkit-keyframes placeholder-glow { + 50% { + opacity: .2; + } + } + + @keyframes placeholder-glow { + 50% { + opacity: .2; + } + } +} diff --git a/app/assets/stylesheets/dark.scss b/app/assets/stylesheets/dark.scss index f7f3a09d..a5a1f837 100644 --- a/app/assets/stylesheets/dark.scss +++ b/app/assets/stylesheets/dark.scss @@ -6,6 +6,7 @@ $cyan: #13fefe; --foreground: #{$white}; --background: #{$black}; --color: #{$cyan}; + --card-border-color: #{rgba($white, .125)}; } .btn { diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 117be995..0fc2440f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,7 +2,7 @@ # Forma de ingreso a Sutty class ApplicationController < ActionController::Base - include ExceptionHandler + include ExceptionHandler if Rails.env.production? include Pundit::Authorization protect_from_forgery with: :null_session, prepend: true @@ -117,4 +117,9 @@ class ApplicationController < ActionController::Base session[:usuarie_return_to] = request.fullpath end + + # Detecta si una petición fue hecha por HTMX + def htmx? + request.headers.key? 'HX-Request' + end end diff --git a/app/controllers/collaborations_controller.rb b/app/controllers/collaborations_controller.rb index 2caa1272..8140b03e 100644 --- a/app/controllers/collaborations_controller.rb +++ b/app/controllers/collaborations_controller.rb @@ -5,9 +5,10 @@ # No necesitamos autenticación aun class CollaborationsController < ApplicationController include Pundit + include StrongParamsHelper def collaborate - @site = Site.find_by_name(params[:site_id]) + @site = Site.find_by_name(pluck_param(:site_id)) authorize Collaboration.new(@site) @invitade = current_usuarie || @site.usuaries.build @@ -21,7 +22,7 @@ class CollaborationsController < ApplicationController # # * Si le usuarie existe y no está logueade, pedirle la contraseña def accept_collaboration - @site = Site.find_by_name(params[:site_id]) + @site = Site.find_by_name(pluck_param(:site_id)) authorize Collaboration.new(@site) @invitade = current_usuarie diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 057c3068..4aa2c8d0 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -2,6 +2,8 @@ # Controlador para artículos class PostsController < ApplicationController + include StrongParamsHelper + before_action :authenticate_usuarie! before_action :service_for_direct_upload, only: %i[new edit] @@ -15,6 +17,82 @@ class PostsController < ApplicationController { locale: locale } end + # @todo Mover a tu propio scope + def new_array + @value = pluck_param(:value) + @name = pluck_param(:name) + id = pluck_param(:id) + + headers['HX-Trigger-After-Swap'] = 'htmx:resetForm' + + render layout: false + end + + def new_array_value + @value = pluck_param(:value) + + render layout: false + end + + def new_related_post + @uuid = pluck_param(:value) + + @indexed_post = site.indexed_posts.find_by!(post_id: @uuid) + + render layout: false + end + + def new_has_one + @uuid = pluck_param(:value) + + @indexed_post = site.indexed_posts.find_by!(post_id: @uuid) + + render layout: false + end + + # El formulario de un Post, si pasamos el UUID, estamos editando, sino + # estamos creando. + def form + uuid = pluck_param(:uuid, optional: true) + locale + + @post = + if uuid.present? + site.indexed_posts.find_by!(post_id: uuid).post + else + # @todo Usar la base de datos + site.posts(lang: locale).build(layout: pluck_param(:layout)) + end + + swap_modals + + render layout: false + end + + # Genera un modal completo + # + # @todo recibir el atributo anterior + # @param :uuid [String] UUID del post (opcional) + # @param :layout [String] El layout a cargar (opcional) + def modal + uuid = pluck_param(:uuid, optional: true) + locale + + # @todo hacer que si el uuid no existe se genera un post, para poder + # pasar el uuid sabiendolo + @post = + if uuid.present? + site.indexed_posts.find_by!(post_id: uuid).post + else + # @todo Usar la base de datos + site.posts(lang: locale).build(layout: pluck_param(:layout)) + end + + swap_modals + + render layout: false + end + def index authorize Post @@ -55,7 +133,7 @@ class PostsController < ApplicationController def new authorize Post - @post = site.posts(lang: locale).build(layout: params[:layout]) + @post = site.posts(lang: locale).build(layout: pluck_param(:layout)) breadcrumb I18n.t('loaf.breadcrumbs.posts.new', layout: @post.layout.humanized_name.downcase), '' end @@ -65,13 +143,34 @@ class PostsController < ApplicationController service = PostService.new(site: site, usuarie: current_usuarie, params: params) - @post = service.create + @post = service.create_or_update - if @post.persisted? + if post.persisted? site.touch forget_content + end - redirect_to site_post_path(@site, @post) + # @todo Enviar la creación a otro endpoint para evitar tantas + # condiciones. + if htmx? + if post.persisted? + triggers = { 'notification:show' => { 'id' => pluck_param(:saved, optional: true) } } + + swap_modals(triggers) + + @value = post.title.value + @uuid = post.uuid.value + @name = pluck_param(:name) + + render render_path_from_attribute, layout: false + else + headers['HX-Retarget'] = "##{pluck_param(:form)}" + headers['HX-Reswap'] = 'outerHTML' + + render 'posts/form', layout: false, post: post, site: site, **params.permit(:form, :base, :dir, :locale) + end + elsif post.persisted? + redirect_to site_post_path(site, post) else render 'posts/new' end @@ -83,6 +182,16 @@ class PostsController < ApplicationController breadcrumb 'posts.edit', '' end + # Este endpoint se encarga de actualizar el post. Si el post se edita + # desde el formulario principal, re-renderizamos el formulario si hay + # errores o enviamos a otro lado al guardar. + # + # Si los datos llegaron por HTMX, hay que regenerar el formulario + # y reemplazarlo en su modal (?) o responder con su tarjeta para + # reemplazarla donde sea que esté. + # + # @todo la re-renderización del formulario no es necesaria si tenemos + # validación client-side. def update authorize post @@ -94,7 +203,37 @@ class PostsController < ApplicationController if service.update.persisted? site.touch forget_content + end + if htmx? + if post.persisted? + triggers = { 'notification:show' => pluck_param(:saved, optional: true) } + + swap_modals(triggers) + + @value = post.title.value + @uuid = post.uuid.value + + if (result_id = pluck_param(:result_id, optional: true)) + headers['HX-Retarget'] = "##{result_id}" + headers['HX-Reswap'] = 'outerHTML' + + @indexed_post = site.indexed_posts.find_by_post_id(post.uuid.value) + + render 'posts/new_related_post', layout: false + # @todo Confirmar que esta ruta no esté transitada + else + @name = pluck_param(:name) + + render render_path_from_attribute, layout: false + end + else + headers['HX-Retarget'] = "##{params.require(:form)}" + headers['HX-Reswap'] = 'outerHTML' + + render 'posts/form', layout: false, post: post, site: site, **params.permit(:form, :base, :dir, :locale) + end + elsif post.persisted? redirect_to site_post_path(site, post) else render 'posts/edit' @@ -168,4 +307,24 @@ class PostsController < ApplicationController def service_for_direct_upload session[:service_name] = site.name.to_sym end + + # @param triggers [Hash] Otros disparadores + def swap_modals(triggers = {}) + params.permit(:show, :hide).each_pair do |key, value| + triggers["modal:#{key}"] = { id: value } if value.present? + end + + headers['HX-Trigger'] = triggers.to_json if triggers.present? + end + + # @return [String] + def render_path_from_attribute + case pluck_param(:attribute) + when 'new_has_many' then 'posts/new_has_many_value' + when 'new_belongs_to' then 'posts/new_belongs_to_value' + when 'new_has_and_belongs_to_many' then 'posts/new_has_many_value' + when 'new_has_one' then 'posts/new_has_one_value' + else 'nothing' + end + end end diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb index c2c7bc58..bc5bb962 100644 --- a/app/controllers/stats_controller.rb +++ b/app/controllers/stats_controller.rb @@ -59,9 +59,6 @@ class StatsController < ApplicationController .order('sum(value) desc') .sum(:value) .transform_values(&:to_i) - .transform_values do |v| - v * nodes - end end end end @@ -73,9 +70,6 @@ class StatsController < ApplicationController stats = rollup_scope.where_dimensions(host: hostnames).multi_series('host', interval: interval).tap do |series| series.each do |serie| serie[:name] = serie.dig(:dimensions, 'host') - serie[:data].transform_values! do |value| - value * nodes - end end end @@ -99,9 +93,6 @@ class StatsController < ApplicationController stats = rollup_scope.where_dimensions(**options).multi_series('host|uri', interval: interval).tap do |series| series.each do |serie| serie[:name] = serie[:dimensions].slice('host', 'uri').values.join.sub('/index.html', '/') - serie[:data].transform_values! do |value| - value * nodes - end end end @@ -197,21 +188,6 @@ class StatsController < ApplicationController end end - # Obtiene la cantidad de nodos de Sutty, para poder calcular la - # cantidad de visitas. - # - # Como repartimos las visitas por nodo rotando las IPs en el - # nameserver y los resolvedores de DNS eligen un nameserver - # aleatoriamente, la cantidad de visitas se reparte - # equitativamente. - # - # XXX: Remover cuando podamos centralizar los AccessLog - # - # @return [Integer] - def nodes - @nodes ||= ENV.fetch('NODES', 1).to_i - end - def period @period ||= begin p = params.permit(:period_start, :period_end) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index fcbd4074..3d074aed 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -2,6 +2,19 @@ # Helpers module ApplicationHelper + BRACKETS = /[\[\]]/.freeze + ALPHA_LARGE = [*'a'..'z', *'A'..'Z'].freeze + + # Devuelve un indentificador aleatorio que puede usarse como atributo + # HTML. Reemplaza Nanoid. El primer caracter siempre es alfabético. + # + # @return [String] + def random_id + SecureRandom.urlsafe_base64.tap do |s| + s[0] = ALPHA_LARGE.sample + end + end + # Devuelve el atributo name de un campo anidado en el formato que # esperan los helpers *_field # @@ -19,6 +32,14 @@ module ApplicationHelper [root, name] end + # Obtiene un ID + # + # @param base [String] + # @param attribute [String, Symbol] + def id_for(base, attribute) + "#{base.gsub(BRACKETS, '_')}_#{attribute}".squeeze('_') + end + def plain_field_name_for(*names) root, name = field_name_for(*names) @@ -134,9 +155,17 @@ module ApplicationHelper private + # Obtiene la traducción desde el esquema en el idioma actual, o por + # defecto en el idioma del sitio. De lo contrario trae una traducción + # genérica. + # + # Si el idioma por defecto tiene un String vacía, se asume que no + # texto. + # + # @return [String,nil] def post_t(*attribute, post:, type:) - post.layout.metadata.dig(*attribute, type.to_s, I18n.locale.to_s) || - post.layout.metadata.dig(*attribute, type.to_s, I18n.default_locale.to_s) || - I18n.t("posts.attributes.#{attribute.join('.')}.#{type}") + post.layout.metadata.dig(*attribute, type.to_s, I18n.locale.to_s).presence || + post.layout.metadata.dig(*attribute, type.to_s, post.site.default_locale.to_s) || + I18n.t("posts.attributes.#{attribute.join('.')}.#{type}").presence end end diff --git a/app/helpers/strong_params_helper.rb b/app/helpers/strong_params_helper.rb new file mode 100644 index 00000000..f248cc50 --- /dev/null +++ b/app/helpers/strong_params_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Métodos reutilizables para trabajar con StrongParams +module StrongParamsHelper + + # Obtiene el valor de un param + # + # @todo No hay una forma mejor de hacer esto? + # @param param [Symbol] + # @param :optional [Bool] + # @return [nil,String] + def pluck_param(param, optional: false) + if optional + params.permit(param).values.first.presence + else + params.require(param).presence + end + end +end diff --git a/app/javascript/controllers/array_controller.js b/app/javascript/controllers/array_controller.js new file mode 100644 index 00000000..5540e839 --- /dev/null +++ b/app/javascript/controllers/array_controller.js @@ -0,0 +1,138 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["item", "search", "current", "placeholder"]; + + connect() { + // TODO: Stimulus >1 + this.newArrayValueURL = new URL(window.location.origin); + + const [ pathname, search ] = this.element.dataset.arrayNewArrayValue.split("?"); + + this.newArrayValueURL.pathname = pathname; + this.newArrayValueURL.search = `?${search}`; + this.originalValue = JSON.parse(this.element.dataset.arrayOriginalValue); + } + + /* + * Al eliminar el ítem, buscamos por su ID y lo eliminamos del + * documento. + */ + remove(event) { + // TODO: Stimulus >1 + event.preventDefault(); + + this.itemTargets + .find((x) => x.id === event.target.dataset.removeTargetParam) + ?.remove(); + } + + /* + * Al buscar, eliminamos las tildes y mayúsculas para no depender de + * cómo se escribió. + * + * Luego buscamos eso en el valor limpio, ignorando los items que ya + * están activados. + * + * Si el término de búsqueda está vacío, volvemos a la lista original. + */ + search(event) { + const needle = this.searchTarget.value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .trim(); + + if (needle) { + for (const itemTarget of this.itemTargets) { + itemTarget.style.display = + itemTarget.querySelector("input")?.checked || + itemTarget.dataset.searchableValue.includes(needle) + ? "" + : "none"; + } + } else { + for (const itemTarget of this.itemTargets) { + itemTarget.style.display = ""; + } + } + } + + /* + * Obtiene el input de un elemento + * + * @param [HTMLElement] + * @return [HTMLElement,nil] + */ + inputFrom(target) { + if (target.tagName === "INPUT") return target; + + return target.querySelector("input"); + } + + /* + * Detecta si el item es o contiene un checkbox/radio activado. + * + * @param [HTMLElement] + * @return [Bool] + */ + isChecked(itemTarget) { + return this.inputFrom(itemTarget)?.checked || false; + } + + cancelWithEscape(event) { + if (event?.key !== "Escape") return; + + this.cancel(); + } + + /* + * Al cancelar, se vuelve al estado original de la lista + */ + cancel(event = undefined) { + for (const itemTarget of this.itemTargets) { + const input = this.inputFrom(itemTarget); + + input.checked = this.originalValue.includes(itemTarget.dataset.value); + } + } + + /* + * Al aceptar, se envía todo el listado de valores nuevos al _backend_ + * para que devuelva la representación de cada ítem en HTML. Además, + * se guarda el nuevo valor como la lista original, para la próxima + * cancelación. + */ + accept(event) { + this.currentTarget.innerHTML = ""; + this.originalValue = []; + + const signal = window.abortController?.signal; + + for (const itemTarget of this.itemTargets) { + if (!itemTarget.dataset.value) continue; + if (!this.isChecked(itemTarget)) continue; + + this.originalValue.push(itemTarget.dataset.value); + this.newArrayValueURL.searchParams.set("value", itemTarget.dataset?.sendValue || itemTarget.dataset?.value); + + const placeholder = this.placeholderTarget.content.firstElementChild.cloneNode(true); + + this.currentTarget.appendChild(placeholder); + + fetch(this.newArrayValueURL, { signal }) + .then((response) => response.text()) + .then((body) => { + const template = document.createElement("template"); + template.innerHTML = body; + + placeholder.replaceWith(template.content.firstElementChild); + }); + } + + // TODO: Stimulus >1 + this.element.dataset.arrayOriginalValue = JSON.stringify( + this.originalValue, + ); + } +} diff --git a/app/javascript/controllers/details_controller.js b/app/javascript/controllers/details_controller.js index 57935e1e..170f482e 100644 --- a/app/javascript/controllers/details_controller.js +++ b/app/javascript/controllers/details_controller.js @@ -1,4 +1,4 @@ -import { Controller } from "stimulus"; +import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static targets = []; diff --git a/app/javascript/controllers/dropdown_controller.js b/app/javascript/controllers/dropdown_controller.js index e2b657fd..bbcc7aec 100644 --- a/app/javascript/controllers/dropdown_controller.js +++ b/app/javascript/controllers/dropdown_controller.js @@ -1,4 +1,4 @@ -import { Controller } from "stimulus"; +import { Controller } from "@hotwired/stimulus"; // https://getbootstrap.com/docs/4.6/components/dropdowns/#single-button export default class extends Controller { diff --git a/app/javascript/controllers/enter_controller.js b/app/javascript/controllers/enter_controller.js new file mode 100644 index 00000000..7f851e8a --- /dev/null +++ b/app/javascript/controllers/enter_controller.js @@ -0,0 +1,10 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + /* + * Previene el envío de un formulario al presionar enter + */ + prevent(event) { + if (event.key == "Enter") event.preventDefault(); + } +} diff --git a/app/javascript/controllers/file_preview_controller.js b/app/javascript/controllers/file_preview_controller.js index 9eaaab2d..58c60496 100644 --- a/app/javascript/controllers/file_preview_controller.js +++ b/app/javascript/controllers/file_preview_controller.js @@ -1,4 +1,4 @@ -import { Controller } from 'stimulus' +import { Controller } from '@hotwired/stimulus' import bsCustomFileInput from "bs-custom-file-input"; document.addEventListener("turbolinks:load", () => { diff --git a/app/javascript/controllers/form_validation_controller.js b/app/javascript/controllers/form_validation_controller.js new file mode 100644 index 00000000..5672a69b --- /dev/null +++ b/app/javascript/controllers/form_validation_controller.js @@ -0,0 +1,53 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["invalid", "submitting"]; + + // @todo Stimulus >1 + get submittingIdValue() { + return this.element.dataset?.formValidationSubmittingIdValue; + } + + // @todo Stimulus >1 + get invalidIdValue() { + return this.element.dataset?.formValidationInvalidIdValue; + } + + connect() { + this.element.setAttribute("novalidate", true); + + for (const input of this.element.elements) { + if (input.type === "button" || input.type === "submit") continue; + + if (input.dataset.action) { + input.dataset.action = `${input.dataset.action} htmx:validation:validate->form-validation#submit`; + } else { + input.dataset.action = "htmx:validation:validate->form-validation#submit"; + } + } + } + + submit(event = undefined) { + if (this.submitting) return; + + this.submitting = true; + + event?.preventDefault(); + + if (this.element.reportValidity()) { + this.element.classList.remove("was-validated"); + + if (!this.element.getAttributeNames().some(x => x.startsWith("hx-"))) this.element.submit(); + + window.dispatchEvent(new CustomEvent("notification:show", { detail: { value: this.submittingIdValue } })); + } else { + event?.stopPropagation(); + + this.element.classList.add("was-validated"); + + window.dispatchEvent(new CustomEvent("notification:show", { detail: { value: this.invalidIdValue } })); + } + + this.submitting = false; + } +} diff --git a/app/javascript/controllers/geo_controller.js b/app/javascript/controllers/geo_controller.js index 0018b01a..db3dab59 100644 --- a/app/javascript/controllers/geo_controller.js +++ b/app/javascript/controllers/geo_controller.js @@ -1,4 +1,4 @@ -import { Controller } from 'stimulus' +import { Controller } from '@hotwired/stimulus' require("leaflet/dist/leaflet.css") import L from 'leaflet' diff --git a/app/javascript/controllers/htmx_controller.js b/app/javascript/controllers/htmx_controller.js new file mode 100644 index 00000000..e7bba7f9 --- /dev/null +++ b/app/javascript/controllers/htmx_controller.js @@ -0,0 +1,107 @@ +import { Controller } from "@hotwired/stimulus"; + +/* + * Un controlador que imita a HTMX + */ +export default class extends Controller { + connect() { + // @todo Convertir en