diff --git a/Dockerfile b/Dockerfile
index 394a81e5..a73a96cc 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -22,8 +22,6 @@ RUN apk add npm && npm install -g pnpm@~7 && apk del npm
COPY ./monit.conf /etc/monit.d/sutty.conf
-RUN apk add npm && npm install -g pnpm && apk del npm
-
VOLUME "/srv"
EXPOSE 3000
diff --git a/Gemfile b/Gemfile
index 5d673dd0..1c185253 100644
--- a/Gemfile
+++ b/Gemfile
@@ -79,8 +79,8 @@ gem 'webpacker'
gem 'yaml_db', git: 'https://0xacab.org/sutty/yaml_db.git'
gem 'kaminari'
gem 'device_detector'
-gem 'htmlbeautifier'
gem 'dry-schema'
+gem 'htmlbeautifier'
gem 'rubanok'
gem 'after_commit_everywhere', '~> 1.0'
@@ -118,9 +118,8 @@ group :development, :test do
gem 'derailed_benchmarks'
gem 'dotenv-rails'
gem 'pry'
- # Adds support for Capybara system testing and selenium driver
- gem 'capybara', '~> 2.13'
- gem 'selenium-webdriver', '~> 4.8.0'
+ gem 'capybara'
+ gem 'selenium-webdriver'
gem 'sqlite3'
end
diff --git a/Gemfile.lock b/Gemfile.lock
index e3be85c1..fc802206 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -118,13 +118,15 @@ GEM
bundler-audit (0.9.1)
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
- capybara (2.18.0)
+ capybara (3.40.0)
addressable
+ matrix
mini_mime (>= 0.1.3)
- nokogiri (>= 1.3.3)
- rack (>= 1.0.0)
- rack-test (>= 0.5.4)
- xpath (>= 2.0, < 4.0)
+ nokogiri (~> 1.11)
+ rack (>= 1.6.0)
+ rack-test (>= 0.6.3)
+ regexp_parser (>= 1.5, < 3.0)
+ xpath (~> 3.2)
chartkick (5.0.2)
climate_control (1.2.0)
coderay (1.1.3)
@@ -360,6 +362,7 @@ GEM
net-pop
net-smtp
marcel (1.0.4)
+ matrix (0.4.2)
memory_profiler (1.0.1)
mercenary (0.4.0)
method_source (1.1.0)
@@ -542,7 +545,7 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
- selenium-webdriver (4.8.6)
+ selenium-webdriver (4.9.1)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
@@ -632,7 +635,7 @@ DEPENDENCIES
bootstrap (~> 4)
brakeman
bundler-audit
- capybara (~> 2.13)
+ capybara
chartkick
commonmarker
concurrent-ruby-ext
@@ -707,7 +710,7 @@ DEPENDENCIES
safe_yaml
safely_block (~> 0.3.0)
sassc-rails
- selenium-webdriver (~> 4.8.0)
+ selenium-webdriver
sourcemap
spring
spring-watcher-listen
diff --git a/Procfile b/Procfile
index a74f613b..d3d8207d 100644
--- a/Procfile
+++ b/Procfile
@@ -1,13 +1,6 @@
-migrate: bundle exec rake db:prepare db:seed
-sutty: bundle exec puma config.ru
-blazer_5m: bundle exec rake blazer:run_checks SCHEDULE="5 minutes"
-blazer_1h: bundle exec rake blazer:run_checks SCHEDULE="1 hour"
-blazer_1d: bundle exec rake blazer:run_checks SCHEDULE="1 day"
-blazer: bundle exec rake blazer:send_failing_checks
-prometheus: bundle exec prometheus_exporter -b 0.0.0.0 --prefix "sutty_"
-distributed_press_tokens_renew: bundle exec rake distributed_press:tokens:renew
cleanup: bundle exec rake cleanup:everything
+distributed_press_tokens_renew: bundle exec rake distributed_press:tokens:renew
emergency_cleanup: bundle exec rake cleanup:everything BEFORE=7
-stats: bundle exec rake stats:process_all
que: daemonize -c /srv/ -p /srv/tmp/que.pid -u rails /usr/local/bin/syslogize bundle exec que
+stats: bundle exec rake stats:process_all
fediblock: bundle exec rails activity_pub:fediblocks
diff --git a/_sites/_storage/.keep b/_sites/_storage/.keep
deleted file mode 100644
index e69de29b..00000000
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 4d1d0848..8482ede0 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,19 @@ $form-feedback-valid-color: $black;
$form-feedback-invalid-color: $magenta;
$form-feedback-icon-valid-color: $black;
$component-active-bg: $magenta;
+$btn-white-space: nowrap;
+$font-weight-bolder: 700;
+$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 +60,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 +98,10 @@ $sizes: (
--foreground: #{$black};
--background: #{$white};
--color: #{$magenta};
+ --card-border-color: #{rgba($black, .125)};
+ --btn-bg-color: #{$black};
+ --btn-color: #{$white};
+ --modal-content-border-color: rgba(#{$black}, .2);
}
@media (prefers-color-scheme: dark) {
@@ -67,34 +109,28 @@ $sizes: (
--foreground: #{$white};
--background: #{$black};
--color: #{$cyan};
+ --card-border-color: #{rgba($white, .250)};
+ --btn-bg-color: #{$white};
+ --btn-color: #{$black};
+ --modal-content-border-color: #{rgba($white, .2)};
}
+
.btn-secondary {
- background-color: $white;
- color: $black;
border: none;
+ }
- &:hover {
- color: $black;
- background-color: $cyan;
- }
+ @include form-validation-state("valid", $cyan, url("data:image/svg+xml,"));
- &:active {
- background-color: $cyan;
- }
-
- &:focus {
- box-shadow: 0 0 0 0.2rem $cyan;
+ .custom-checkbox {
+ .custom-control-input:checked ~ .custom-control-label {
+ &::after {
+ background-image: url("data:image/svg+xml,");
+ }
}
}
}
-// TODO: Encontrar la forma de generar esto desde los locales de Rails
-$custom-file-text: (
- en: 'Browse',
- es: 'Buscar archivo'
-);
-
@font-face {
font-family: 'Saira';
font-style: normal;
@@ -135,6 +171,10 @@ a {
color: var(--color);
}
+ &:focus {
+ outline: 1px solid var(--color);
+ }
+
&[target=_blank] {
/* TODO: Convertir a base64 para no hacer peticiones extra */
&:after {
@@ -241,6 +281,8 @@ svg {
.btn {
margin-right: 0.3rem;
margin-bottom: 0.3rem;
+ background-color: $btn-bg-color;
+ color: $btn-color;
&:hover {
color: var(--background);
@@ -256,6 +298,10 @@ svg {
}
}
+.badge {
+ white-space: break-spaces;
+}
+
.btn-sm {
@extend .badge
}
@@ -318,10 +364,6 @@ svg {
}
}
-.custom-control-label {
- font-weight: bold;
-}
-
.designs {
.design {
margin-top: 1rem;
@@ -621,3 +663,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..e8ed1862 100644
--- a/app/assets/stylesheets/dark.scss
+++ b/app/assets/stylesheets/dark.scss
@@ -6,29 +6,13 @@ $cyan: #13fefe;
--foreground: #{$white};
--background: #{$black};
--color: #{$cyan};
-}
-
-.btn {
- background-color: $white;
+ --card-border-color: #{rgba($white, .250)};
+ --btn-bg-color: #{$white};
+ --btn-color: #{$black};
}
.btn-secondary {
- background-color: $white;
- color: $black;
border: none;
-
- &:hover {
- color: $black;
- background-color: $cyan;
- }
-
- &:active {
- background-color: $cyan;
- }
-
- &:focus {
- box-shadow: 0 0 0 0.2rem $cyan;
- }
}
diff --git a/app/controllers/active_storage/disk_controller_decorator.rb b/app/controllers/active_storage/disk_controller_decorator.rb
index ec3ac0b4..21c0ed33 100644
--- a/app/controllers/active_storage/disk_controller_decorator.rb
+++ b/app/controllers/active_storage/disk_controller_decorator.rb
@@ -11,7 +11,7 @@ module ActiveStorage
# Permitir incrustar archivos subidos (especialmente PDFs) desde
# otros sitios.
def show
- original_show.tap do |s|
+ original_show.tap do |_s|
response.headers.delete 'X-Frame-Options'
end
end
@@ -24,7 +24,7 @@ module ActiveStorage
if (token = decode_verified_token)
if acceptable_content?(token)
blob = ActiveStorage::Blob.find_by_key! token[:key]
- site = Site.find_by_name token[:service_name]
+ site = Site.find_by_name! token[:service_name]
if remote_file?(token)
begin
@@ -32,18 +32,20 @@ module ActiveStorage
body = Down.download(url, max_size: 111.megabytes)
checksum = Digest::MD5.file(body.path).base64digest
blob.metadata[:url] = url
- blob.update_columns checksum: checksum, byte_size: body.size, metadata: blob.metadata
+ blob.update_columns checksum:, byte_size: body.size, metadata: blob.metadata
rescue StandardError => e
- ExceptionNotifier.notify_exception(e, data: { key: token[:key], url: url, site: site.name })
+ ExceptionNotifier.notify_exception(e, data: { key: token[:key], url:, site: site.name })
head :content_too_large
+
+ return
end
else
body = request.body
checksum = token[:checksum]
end
- named_disk_service(token[:service_name]).upload token[:key], body, checksum: checksum
+ named_disk_service(site.name).upload(token[:key], body, checksum:)
site.static_files.attach(blob)
else
@@ -52,8 +54,14 @@ module ActiveStorage
else
head :not_found
end
- rescue ActiveStorage::IntegrityError
+ rescue ActiveRecord::ActiveRecordError, ActiveStorage::Error => e
+ ExceptionNotifier.notify_exception(e, data: { token: })
+
head :unprocessable_entity
+ rescue Down::Error => e
+ ExceptionNotifier.notify_exception(e, data: { key: token[:key], url: url, site: site.name })
+
+ head :payload_too_large
end
private
@@ -64,7 +72,10 @@ module ActiveStorage
def page_not_found(exception)
head :not_found
- ExceptionNotifier.notify_exception(exception, data: {params: params.to_hash})
+
+ params.permit!
+
+ ExceptionNotifier.notify_exception(exception, data: { params: params.to_hash })
end
end
end
diff --git a/app/controllers/api/v1/sites_controller.rb b/app/controllers/api/v1/sites_controller.rb
index 05abc38a..8f3cbaa2 100644
--- a/app/controllers/api/v1/sites_controller.rb
+++ b/app/controllers/api/v1/sites_controller.rb
@@ -4,51 +4,88 @@ module Api
module V1
# API para sitios
class SitesController < BaseController
- http_basic_authenticate_with name: ENV['HTTP_BASIC_USER'],
- password: ENV['HTTP_BASIC_PASSWORD']
+ SUBDOMAIN = ".#{Site.domain}"
+ TESTING_SUBDOMAIN = ".testing.#{Site.domain}"
+ PARTS = Site.domain.split('.').count
+
+ if Rails.env.production?
+ http_basic_authenticate_with name: ENV['HTTP_BASIC_USER'],
+ password: ENV['HTTP_BASIC_PASSWORD']
+ end
# Lista de nombres de dominios a emitir certificados
def index
- render json: alternative_names + api_names + www_names
+ all_names = sites_names.concat(alternative_names).concat(www_names).concat(api_names).uniq.map do |name|
+ canonicalize name
+ end.reject do |name|
+ subdomain? name
+ end.reject do |name|
+ testing? name
+ end.uniq
+
+ render json: all_names
end
private
+ # @param query [ActiveRecord::Relation]
+ # @return [Array]
+ def hostname_of(query)
+ query.pluck(Arel.sql("values->>'hostname'")).compact.uniq
+ end
+
def canonicalize(name)
name.end_with?('.') ? name[0..-2] : "#{name}.#{Site.domain}"
end
+ # Es un subdominio directo del dominio principal
+ #
+ # @param name [String]
+ # @return [Bool]
def subdomain?(name)
- name.end_with? ".#{Site.domain}"
+ name.end_with?(SUBDOMAIN) && name.split('.').count == (PARTS + 1)
end
- # Dominios alternativos
+ # Es un dominio de prueba
+ #
+ # @param name [String]
+ # @return [Bool]
+ def testing?(name)
+ name.end_with?(TESTING_SUBDOMAIN) && name.split('.').count == (PARTS + 2)
+ end
+
+ # Nombres de los sitios
+ #
+ # @param name [String]
+ # @return [Array]
+ def sites_names
+ Site.all.order(:name).pluck(:name)
+ end
+
+ # Dominios alternativos, incluyendo todas las clases derivadas de
+ # esta.
+ #
+ # @return [Array]
def alternative_names
- (DeployAlternativeDomain.all.map(&:hostname) + DeployLocalizedDomain.all.map(&:hostname)).map do |name|
- canonicalize name
- end.reject do |name|
- subdomain? name
- end
+ hostname_of(DeployAlternativeDomain.all)
end
# Obtener todos los sitios con API habilitada, es decir formulario
# de contacto y/o colaboración anónima.
#
- # TODO: Optimizar
+ # @return [Array]
def api_names
Site.where(contact: true)
.or(Site.where(colaboracion_anonima: true))
- .select("'api.' || name as name").map(&:name).map do |name|
- canonicalize name
- end.reject do |name|
- subdomain? name
+ .pluck(:name).map do |name|
+ "api.#{name}"
end
end
# Todos los dominios con WWW habilitado
def www_names
- Site.where(id: DeployWww.all.pluck(:site_id)).select("'www.' || name as name").map(&:name).map do |name|
- canonicalize name
+ Site.where(id: DeployWww.all.pluck(:site_id)).pluck(:name).map do |name|
+ "www.#{name}"
end
end
end
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/concerns/exception_handler.rb b/app/controllers/concerns/exception_handler.rb
index 7c1cd540..3925f42c 100644
--- a/app/controllers/concerns/exception_handler.rb
+++ b/app/controllers/concerns/exception_handler.rb
@@ -10,25 +10,41 @@ module ExceptionHandler
included do
rescue_from SiteNotFound, with: :site_not_found
rescue_from PageNotFound, with: :page_not_found
- rescue_from ActionController::RoutingError, with: :page_not_found
- rescue_from Pundit::NilPolicyError, with: :page_not_found
+ rescue_from Pundit::Error, with: :page_not_found
+ rescue_from Pundit::NotAuthorizedError, with: :page_unauthorized
rescue_from Pundit::NilPolicyError, with: :page_not_found
rescue_from ActionController::RoutingError, with: :page_not_found
rescue_from ActionController::ParameterMissing, with: :page_not_found
end
- def site_not_found
+ def site_not_found(exception)
reset_response!
flash[:error] = I18n.t('errors.site_not_found')
+ ExceptionNotifier.notify_exception(exception, data: { usuarie: current_usuarie&.id, path: request.fullpath })
+
redirect_to sites_path
end
- def page_not_found
+ def page_unauthorized(exception)
reset_response!
- render 'application/page_not_found', status: :not_found
+ flash[:error] = I18n.t('errors.page_unauthorized')
+
+ ExceptionNotifier.notify_exception(exception, data: { usuarie: current_usuarie&.id, path: request.fullpath })
+
+ redirect_to site_path(site)
+ end
+
+ def page_not_found(exception)
+ reset_response!
+
+ flash[:error] = I18n.t('errors.page_not_found')
+
+ ExceptionNotifier.notify_exception(exception)
+
+ redirect_to site_path(site)
end
private
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index 057c3068..7974d317 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]
@@ -13,6 +15,84 @@ class PostsController < ApplicationController
# Las URLs siempre llevan el idioma actual o el de le usuarie
def default_url_options
{ locale: locale }
+ rescue SiteNotFound
+ {}
+ 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
@@ -55,7 +135,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 +145,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 +184,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 +205,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 +309,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/registrations_controller.rb b/app/controllers/registrations_controller.rb
new file mode 100644
index 00000000..b976f514
--- /dev/null
+++ b/app/controllers/registrations_controller.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+# Modificaciones locales al registro de usuaries
+#
+# @see {https://github.com/heartcombo/devise/wiki/How-To:-Use-Recaptcha-with-Devise}
+class RegistrationsController < Devise::RegistrationsController
+ class SpambotError < StandardError; end
+
+ PRIVATE_HEADERS = /(cookie|secret|token)/i
+
+ prepend_before_action :anti_spambot_traps, only: %i[create]
+ prepend_after_action :lock_spambots, only: %i[create]
+
+ private
+
+ # Condiciones bajo las que consideramos que un registro viene de unx
+ # spambot
+ #
+ # @return [Bool]
+ def spambot?
+ @spambot ||= params.dig(:usuarie, :name).present?
+ end
+
+ # Bloquea las cuentas de spam dentro de un minuto, para hacerles creer
+ # que la cuenta se creó correctamente.
+ def lock_spambots
+ return unless spambot?
+ return unless current_usuarie
+
+ LockUsuarieJob.set(wait: 1.minute).perform_later(usuarie: current_usuarie)
+ end
+
+ # Detecta e informa spambots muy simples
+ #
+ # @return [nil]
+ def anti_spambot_traps
+ raise SpambotError if spambot?
+ rescue SpambotError => e
+ ExceptionNotifier.notify_exception(e, data: { params: anonymized_params, headers: anonymized_headers })
+ nil
+ end
+
+ # Devuelve parámetros anonimizados para prevenir filtrar la contraseña
+ # de falsos positivos.
+ #
+ # @return [Hash]
+ def anonymized_params
+ params.except(:authenticity_token).permit!.to_h.tap do |p|
+ p['usuarie'].delete 'password'
+ p['usuarie'].delete 'password_confirmation'
+ end
+ end
+
+ # Devuelve los encabezados de la petición sin información sensible de
+ # Rails
+ #
+ # @return [Hash]
+ def anonymized_headers
+ request.headers.to_h.select do |_, v|
+ v.is_a? String
+ end.reject do |k, _|
+ k =~ PRIVATE_HEADERS
+ end
+ end
+
+ # Si le usuarie es considerade spambot, no enviamos el correo de
+ # confirmación al crear la cuenta.
+ def sign_up_params
+ if spambot?
+ params[:usuarie][:confirmed_at] = Time.now.utc
+
+ devise_parameter_sanitizer.permit(:sign_up, keys: %i[confirmed_at])
+ end
+
+ super
+ end
+end
diff --git a/app/controllers/usuaries_controller.rb b/app/controllers/usuaries_controller.rb
index 6924c860..cba349c5 100644
--- a/app/controllers/usuaries_controller.rb
+++ b/app/controllers/usuaries_controller.rb
@@ -29,7 +29,7 @@ class UsuariesController < ApplicationController
@usuarie = Usuarie.find(params[:id])
- if @site.usuaries.count > 1
+ if @site.invitade?(@usuarie) || @site.usuaries.count > 1
# Mágicamente elimina el rol
@usuarie.sites.delete(@site)
else
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
+ this.placeholder = "";
+ }
+
+ /*
+ * Obtiene la URL y elimina la acción.
+ *
+ * @param event [Event]
+ */
+ getUrlOnce(event) {
+ this.getUrl(event);
+
+ event.target.dataset.action = event.target.dataset.action.replace("htmx#getUrlOnce", "").trim();
+ }
+
+ /*
+ * Lanza el evento que va a descargar la URL y agregarse en algún
+ * lado.
+ *
+ * @param event [Event]
+ */
+ getUrl(event) {
+ // @todo Stimulus >1
+ const value = event.target.dataset.htmxGetUrlParam;
+
+ if (value) {
+ window.dispatchEvent(new CustomEvent("htmx:getUrl", { detail: { value } }));
+ } else {
+ console.error("Missing data-htmx-get-url-param attribute on element", event.target);
+ }
+ }
+
+ /*
+ * Realiza una petición.
+ *
+ * @param url [String]
+ * @return [Promise]
+ */
+ async request(url) {
+ const headers = new Headers();
+ const signal = window.abortController?.signal;
+
+ headers.set("HX-Request", "true");
+
+ return fetch(url, { headers, signal });
+ }
+
+ /*
+ * Obtiene la URL enviada por el evento y reemplaza el contenido del
+ * elemento.
+ */
+ async swap(event) {
+ const response = await this.request(event.detail.value);
+
+ if (response.ok) {
+ this.element.innerHTML = this.placeholder;
+ this.element.innerHTML = await response.text();
+ this.triggerEvents(response.headers);
+ window.htmx.process(this.element);
+ } else {
+ console.error(response);
+ }
+ }
+
+ /*
+ * Agrega el resultado de la descarga al final del elemento.
+ */
+ async beforeend(event) {
+ const response = await this.request(event.detail.value);
+
+ if (response.ok) {
+ this.element.insertAdjacentHTML("beforeend", this.placeholder);
+ this.element.lastElementChild.outerHTML = await response.text();
+
+ this.triggerEvents(response.headers);
+
+ // @todo Asume que cada endpoint solo devuelve un elemento por vez
+ window.htmx.process(this.element.lastElementChild);
+ } else {
+ console.error(response);
+ }
+ }
+
+ /*
+ * Lanza los eventos que vienen con la respuesta.
+ */
+ triggerEvents(headers) {
+ if (!headers.has("HX-Trigger")) return;
+
+ const events = JSON.parse(headers.get("HX-Trigger"));
+
+ setTimeout(() => {
+ for (const event in events) {
+ const detail = events[event];
+
+ window.dispatchEvent(new CustomEvent(event, { detail }));
+ }
+ }, 1);
+ }
+}
diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js
index 6f53d84b..8d0381e5 100644
--- a/app/javascript/controllers/index.js
+++ b/app/javascript/controllers/index.js
@@ -1,8 +1,8 @@
// Load all the controllers within this directory and all subdirectories.
// Controller files must be named *_controller.js.
-import { Application } from "stimulus"
-import { definitionsFromContext } from "stimulus/webpack-helpers"
+import { Application } from "@hotwired/stimulus"
+import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers"
const application = Application.start()
const context = require.context("controllers", true, /_controller\.js$/)
diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js
new file mode 100644
index 00000000..8b2406a3
--- /dev/null
+++ b/app/javascript/controllers/modal_controller.js
@@ -0,0 +1,95 @@
+import { Controller } from "@hotwired/stimulus";
+
+export default class extends Controller {
+ static targets = ["modal", "backdrop"];
+
+ // TODO: Stimulus >1
+ connect() {
+ this.showEvent = this.show.bind(this);
+ this.hideEvent = this.hide.bind(this);
+
+ window.addEventListener("modal:show", this.showEvent);
+ window.addEventListener("modal:hide", this.hideEvent);
+ }
+
+ // TODO: Stimulus >1
+ disconnect() {
+ window.removeEventListener("modal:show", this.showEvent);
+ window.removeEventListener("modal:hide", this.hideEvent);
+ }
+
+ /*
+ * Abrir otro modal, enviando el ID a toda la ventana.
+ */
+ showAnother(event = undefined) {
+ event?.preventDefault();
+
+ if (!event.target?.dataset?.modalShowValue) return;
+
+ window.dispatchEvent(new CustomEvent("modal:show", { detail: { id: event.target.dataset.modalShowValue, previousFocus: event.target.id } }));
+ }
+
+ /*
+ * Podemos enviar la orden de apertura como un click o como un
+ * CustomEvent incluyendo el id del modal como detail.
+ *
+ * El elemento clicleable puede tener un valor que se refiera a otro
+ * modal también.
+ */
+ show(event = undefined) {
+ event?.preventDefault();
+ const modalId = event?.detail?.id;
+
+ if (modalId && this.element.id !== modalId) return;
+
+ this.modalTarget.style.display = "block";
+ this.backdropTarget.style.display = "block";
+ this.modalTarget.setAttribute("role", "dialog");
+ this.modalTarget.setAttribute("aria-modal", true);
+ this.modalTarget.removeAttribute("aria-hidden");
+
+ window.document.body.classList.add("modal-open");
+
+ if (event?.detail?.previousFocus) {
+ this.previousFocus = window.document.getElementById(event.detail.previousFocus);
+ } else {
+ this.previousFocus = event?.target;
+ }
+
+ setTimeout(() => {
+ this.modalTarget.classList.add("show");
+ this.backdropTarget.classList.add("show");
+
+ this.modalTarget.focus();
+ }, 1);
+ }
+
+ hideWithEscape(event) {
+ if (event?.key !== "Escape") return;
+
+ this.hide();
+ }
+
+ hide(event = undefined) {
+ event?.preventDefault();
+ const modalId = event?.detail?.id;
+
+ if (modalId && this.element.id !== modalId) return;
+
+ this.backdropTarget.classList.remove("show");
+ this.modalTarget.classList.remove("show");
+
+ this.modalTarget.setAttribute("aria-hidden", true);
+ this.modalTarget.removeAttribute("role");
+ this.modalTarget.removeAttribute("aria-modal");
+
+ this.previousFocus?.focus();
+
+ setTimeout(() => {
+ this.modalTarget.style.display = "";
+ this.backdropTarget.style.display = "";
+ }, 500);
+
+ window.document.body.classList.remove("modal-open");
+ }
+}
diff --git a/app/javascript/controllers/new_editor_controller.js b/app/javascript/controllers/new_editor_controller.js
new file mode 100644
index 00000000..82af0fa9
--- /dev/null
+++ b/app/javascript/controllers/new_editor_controller.js
@@ -0,0 +1,18 @@
+import { Controller } from "@hotwired/stimulus";
+import SuttyEditor from "@suttyweb/editor";
+
+import "@suttyweb/editor/dist/style.css";
+
+export default class extends Controller {
+ static targets = ["textarea"];
+
+ connect() {
+ this.editor =
+ new SuttyEditor({
+ target: this.element,
+ props: {
+ textareaEl: this.textareaTarget,
+ },
+ });
+ }
+}
diff --git a/app/javascript/controllers/non_geo_controller.js b/app/javascript/controllers/non_geo_controller.js
index 1c618fcb..c9cbbef0 100644
--- a/app/javascript/controllers/non_geo_controller.js
+++ b/app/javascript/controllers/non_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/notification_controller.js b/app/javascript/controllers/notification_controller.js
new file mode 100644
index 00000000..a1590672
--- /dev/null
+++ b/app/javascript/controllers/notification_controller.js
@@ -0,0 +1,43 @@
+import { Controller } from "@hotwired/stimulus";
+
+/*
+ * Solo se puede mostrar una notificación a la vez
+ */
+export default class extends Controller {
+ // @todo Stimulus >1
+ get showClasses() {
+ return (this.element.dataset?.notificationShowClass || "").split(" ").filter(x => x);
+ }
+
+ // @todo Stimulus >1
+ get hideClasses() {
+ return (this.element.dataset?.notificationHideClass || "").split(" ").filter(x => x);
+ }
+
+ /*
+ * Al recibir el evento de mostrar, si no está dirigido al elemento
+ * actual, se oculta.
+ */
+ show(event = undefined) {
+ if (event?.detail?.value !== this.element.id) {
+ this.hide({ detail: { value: this.element.id } });
+ return;
+ }
+
+ this.element.classList.remove("d-none");
+
+ setTimeout(() => {
+ this.element.classList.remove(...this.hideClasses);
+ this.element.classList.add(...this.showClasses);
+ }, 1);
+ }
+
+ hide(event = undefined) {
+ if (event?.detail?.value !== this.element.id) return;
+
+ this.element.classList.remove(...this.showClasses);
+ this.element.classList.add(...this.hideClasses);
+
+ setTimeout(() => this.element.classList.add("d-none"), 150);
+ }
+}
diff --git a/app/javascript/controllers/reorder_controller.js b/app/javascript/controllers/reorder_controller.js
index 2cba4163..2e851c32 100644
--- a/app/javascript/controllers/reorder_controller.js
+++ b/app/javascript/controllers/reorder_controller.js
@@ -1,4 +1,4 @@
-import { Controller } from 'stimulus'
+import { Controller } from '@hotwired/stimulus'
/*
* Permite reordenar las filas de una tabla.
diff --git a/app/javascript/controllers/required_checkbox_controller.js b/app/javascript/controllers/required_checkbox_controller.js
new file mode 100644
index 00000000..eb0050fe
--- /dev/null
+++ b/app/javascript/controllers/required_checkbox_controller.js
@@ -0,0 +1,53 @@
+import { Controller } from "@hotwired/stimulus";
+
+/*
+ * Para poder indicar que al menos uno del grupo de checkboxes es
+ * obligatorio, marcamos uno como `required` (que es el que mostraría el
+ * error) y se lo quitamos cuando detectamos que alguno cambió.
+ */
+export default class extends Controller {
+ static targets = ["required", "checkbox"];
+
+ connect() {
+ }
+
+ checkboxTargetConnected(checkboxTarget) {
+ if (checkboxTarget.checked) {
+ this.requiredTarget.required = false;
+ this.revalid();
+ }
+ }
+
+ /*
+ * El grupo deja de ser obligatorio cuando al menos uno está activo.
+ */
+ change(event = undefined) {
+ if (event.target.checked) {
+ this.requiredTarget.required = false;
+ } else {
+ this.requiredTarget.required = !Array.from(this.checkboxTargets).some(x => x.checked);
+ }
+
+ for (const checkbox of this.checkboxTargets) {
+ if (checkbox === event.target) continue;
+
+ checkbox.required = !event.target.checked;
+ }
+ }
+
+ /*
+ * Si el checkbox es considerado inválido, transmitir todos los
+ * estados a los checkboxes.
+ */
+ invalid(event = undefined) {
+ for (const checkbox of this.checkboxTargets) {
+ checkbox.required = true;
+ }
+ }
+
+ revalid(event = undefined) {
+ for (const checkbox of this.checkboxTargets) {
+ checkbox.required = false;
+ }
+ }
+}
diff --git a/app/javascript/controllers/select_all_controller.js b/app/javascript/controllers/select_all_controller.js
index 7aca0f59..8d17209f 100644
--- a/app/javascript/controllers/select_all_controller.js
+++ b/app/javascript/controllers/select_all_controller.js
@@ -1,4 +1,4 @@
-import { Controller } from "stimulus";
+import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["toggle", "input"];
diff --git a/app/javascript/controllers/unsaved_changes_controller.js b/app/javascript/controllers/unsaved_changes_controller.js
new file mode 100644
index 00000000..e7c03627
--- /dev/null
+++ b/app/javascript/controllers/unsaved_changes_controller.js
@@ -0,0 +1,55 @@
+import { Controller } from "@hotwired/stimulus";
+
+export default class extends Controller {
+ connect() {
+ this.originalFormDataSerialized = this.serializeFormData(this.element);
+ this.submitting = false;
+ }
+
+ submit(event) {
+ this.submitting = true;
+ }
+
+ unsaved(event) {
+ if (this.submitting) return;
+ if (!this.hasChanged()) return;
+
+ this.submitting = false;
+
+ event.preventDefault();
+
+ event.returnValue = true;
+ }
+
+ unsavedTurbolinks(event) {
+ if (this.submitting) return;
+ if (!this.hasChanged()) return;
+
+ this.submitting = false;
+
+ if (window.confirm(this.element.dataset.unsavedChangesConfirmValue)) return;
+
+ event.preventDefault();
+ }
+
+ formData(form) {
+ const formData = new FormData(form);
+
+ formData.delete("authenticity_token");
+
+ return formData;
+ }
+
+ /*
+ * Elimina saltos de línea y espacios al serializar, para evitar
+ * detectar cambios cuando cambió el espaciado, por ejemplo cuando el
+ * editor con formato aplica espacios o elimina saltos de línea.
+ */
+ serializeFormData(form) {
+ return (new URLSearchParams(this.formData(form))).toString().replaceAll("+", "").replaceAll("%0A", "");
+ }
+
+ hasChanged() {
+ return (this.originalFormDataSerialized !== this.serializeFormData(this.element));
+ }
+}
diff --git a/app/javascript/etc/htmx_abort.js b/app/javascript/etc/htmx_abort.js
index 308d0315..75e497ba 100644
--- a/app/javascript/etc/htmx_abort.js
+++ b/app/javascript/etc/htmx_abort.js
@@ -5,3 +5,7 @@ document.addEventListener("turbolinks:click", () => {
window.htmx.trigger(hx, "htmx:abort");
}
});
+
+document.addEventListener("htmx:resetForm", (event) => {
+ event.target.reset();
+});
diff --git a/app/javascript/etc/index.js b/app/javascript/etc/index.js
index 3a1ef75c..7dd3671b 100644
--- a/app/javascript/etc/index.js
+++ b/app/javascript/etc/index.js
@@ -4,6 +4,4 @@ import './input-tag'
import './prosemirror'
import './timezone'
import './turbolinks-anchors'
-import './validation'
-import './new_editor'
import './htmx_abort'
diff --git a/app/javascript/etc/new_editor.js b/app/javascript/etc/new_editor.js
deleted file mode 100644
index dbc87bbc..00000000
--- a/app/javascript/etc/new_editor.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import SuttyEditor from "@suttyweb/editor";
-
-import "@suttyweb/editor/dist/style.css";
-
-document.addEventListener("turbolinks:load", () => {
- document.querySelectorAll(".new-editor").forEach((editorContainer) => {
- new SuttyEditor({
- target: editorContainer,
- props: {
- textareaEl: editorContainer.querySelector("textarea"),
- },
- });
- });
-});
diff --git a/app/javascript/etc/validation.js b/app/javascript/etc/validation.js
deleted file mode 100644
index 5a48148f..00000000
--- a/app/javascript/etc/validation.js
+++ /dev/null
@@ -1,34 +0,0 @@
-document.addEventListener('turbolinks:load', () => {
- // Al enviar el formulario del artículo, aplicar la validación
- // localmente y actualizar los comentarios para lectores de pantalla.
- document.querySelectorAll('form').forEach(form => {
- form.addEventListener('submit', event => {
- const invalid_help = form.querySelectorAll('.invalid-help')
- const sending_help = form.querySelectorAll('.sending-help')
-
- invalid_help.forEach(i => i.classList.add('d-none'))
- sending_help.forEach(i => i.classList.add('d-none'))
-
- form.querySelectorAll('[aria-invalid="true"]').forEach(aria => {
- aria.setAttribute('aria-invalid', false)
- aria.setAttribute('aria-describedby', aria.parentElement.querySelector('.feedback').id)
- })
-
- if (form.checkValidity() === false) {
- event.preventDefault()
- event.stopPropagation()
-
- invalid_help.forEach(i => i.classList.remove('d-none'))
-
- form.querySelectorAll(':invalid').forEach(invalid => {
- invalid.setAttribute('aria-invalid', true)
- invalid.setAttribute('aria-describedby', invalid.parentElement.querySelector('.invalid-feedback').id)
- })
- } else {
- sending_help.forEach(i => i.classList.remove('d-none'))
- }
-
- form.classList.add('was-validated')
- })
- })
-})
diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js
index e10e2b5d..a0f18024 100644
--- a/app/javascript/packs/application.js
+++ b/app/javascript/packs/application.js
@@ -41,4 +41,5 @@ Rails.start()
Turbolinks.start()
ActiveStorage.start()
-window.htmx = require('htmx.org/dist/htmx.js')
+window.htmx = require("@suttyweb/htmx.org/dist/htmx.cjs.js");
+window.htmx.config.selfRequestsOnly = true;
diff --git a/app/jobs/add_full_rsync_job.rb b/app/jobs/add_full_rsync_job.rb
new file mode 100644
index 00000000..10eba6bb
--- /dev/null
+++ b/app/jobs/add_full_rsync_job.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+# Agrega un nodo nuevo en segundo plano y sincroniza todos los sitios
+class AddFullRsyncJob < ApplicationJob
+ # Obtiene todos los sitios que estén sincronizando con un nodo de
+ # Sutty, agrega el nodo nuevo y empieza la sincronización.
+ #
+ # @param :hostname [String] El nombre del servidor remoto
+ # @param :destination [String] La ubicación de rsync
+ def perform(hostname:, destination:)
+ site_ids = DeployFullRsync.all.distinct.pluck(:site_id)
+
+ Site.where(id: site_ids).find_each do |site|
+ site
+ .deploys
+ .create(
+ type: 'DeployFullRsync',
+ destination: destination,
+ hostname: hostname
+ ).deploy
+ end
+ end
+end
diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb
index ee4e3b2c..d0cb4605 100644
--- a/app/jobs/application_job.rb
+++ b/app/jobs/application_job.rb
@@ -4,6 +4,8 @@
class ApplicationJob < ActiveJob::Base
include Que::ActiveJob::JobExtensions
+ attr_reader :site
+
# Esperar una cantidad random de segundos primos, para que no se
# superpongan tareas
#
@@ -15,8 +17,6 @@ class ApplicationJob < ActiveJob::Base
RANDOM_WAIT.sample.seconds
end
- attr_reader :site
-
# Si falla por cualquier cosa informar y descartar
discard_on(Exception) do |job, error|
ExceptionNotifier.notify_exception(error, data: { job: job })
diff --git a/app/jobs/deploy_job.rb b/app/jobs/deploy_job.rb
index 66cccd1b..b91f4d0d 100644
--- a/app/jobs/deploy_job.rb
+++ b/app/jobs/deploy_job.rb
@@ -77,6 +77,8 @@ class DeployJob < ApplicationJob
t << ([type.to_s] + row.values)
end
end)
+ rescue DeployTimedOutException => e
+ notify_exception e
ensure
if site.present?
site.update status: 'waiting'
diff --git a/app/jobs/git_pull_job.rb b/app/jobs/git_pull_job.rb
index 72e20be0..30431495 100644
--- a/app/jobs/git_pull_job.rb
+++ b/app/jobs/git_pull_job.rb
@@ -1,18 +1,29 @@
# frozen_string_literal: true
-# Permite traer los cambios desde webhooks
-
+# Permite traer los cambios desde el repositorio remoto
class GitPullJob < ApplicationJob
# @param :site [Site]
# @param :usuarie [Usuarie]
+ # @param :message [String]
# @return [nil]
- def perform(site, usuarie)
+ def perform(site, usuarie, message)
@site = site
return unless site.repository.origin
- return unless site.repository.fetch.positive?
- site.repository.merge(usuarie)
+ site.repository.fetch
+
+ return if site.repository.up_to_date?
+
+ if site.repository.fast_forward?
+ site.repository.fast_forward!
+ else
+ site.repository.merge(usuarie, message)
+ end
+
+ site.repository.git_lfs_checkout
site.reindex_changes!
+
+ nil
end
end
diff --git a/app/jobs/lock_usuarie_job.rb b/app/jobs/lock_usuarie_job.rb
new file mode 100644
index 00000000..8af6ee83
--- /dev/null
+++ b/app/jobs/lock_usuarie_job.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# Bloquea el acceso a une usuarie
+class LockUsuarieJob < ApplicationJob
+ # Cambiamos la contraseña, aplicamos un bloqueo y cerramos la sesión
+ # para que no pueda volver a entrar hasta que siga las instrucciones
+ # de desbloqueo.
+ #
+ # @param :usuarie [Usuarie]
+ # @return [nil]
+ def perform(usuarie:)
+ password = SecureRandom.base36
+
+ usuarie.skip_password_change_notification!
+ usuarie.update(password: password, password_confirmation: password, remember_created_at: nil, locked_at: Time.now.utc)
+
+ nil
+ end
+end
diff --git a/app/lib/concerns/auto_publish_concern.rb b/app/lib/concerns/auto_publish_concern.rb
new file mode 100644
index 00000000..19160f02
--- /dev/null
+++ b/app/lib/concerns/auto_publish_concern.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+
+module AutoPublishConcern
+ extend ActiveSupport::Concern
+
+ included do
+ def auto_publish!
+ DeployJob.perform_later(site) if site.auto_publish?
+ end
+ end
+end
diff --git a/app/lib/core_extensions/string/remove_diacritics.rb b/app/lib/core_extensions/string/remove_diacritics.rb
new file mode 100644
index 00000000..679db13d
--- /dev/null
+++ b/app/lib/core_extensions/string/remove_diacritics.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module CoreExtensions
+ module String
+ # Elimina tildes
+ module RemoveDiacritics
+ def remove_diacritics
+ unicode_normalize(:nfd).gsub(/[^\x00-\x7F]/, '')
+ end
+ end
+ end
+end
diff --git a/app/mailers/activity_pub/actor_flagged_mailer.rb b/app/mailers/activity_pub/actor_flagged_mailer.rb
new file mode 100644
index 00000000..ccf24568
--- /dev/null
+++ b/app/mailers/activity_pub/actor_flagged_mailer.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class ActivityPub
+ # Notifica a les moderadores cuando un sitio fue reportado.
+ class ActorFlaggedMailer < ::ApplicationMailer
+ # Envía correo a cada moderadore en su idioma
+ #
+ # @param :site_id [String,Integer]
+ # @param :site_name [String]
+ # @param :content [String]
+ def notify_moderators
+ Usuarie.moderators.pluck(:lang, :email).group_by(&:first).transform_values do |value|
+ value.last
+ end.each_pair do |lang, emails|
+ I18n.with_locale(lang) do
+ emails.each do |email|
+ mail to: email, subject: I18n.t('activity_pub.actor_flagged_mailer.notify_moderators.subject')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/activity_pub/activity/flag.rb b/app/models/activity_pub/activity/flag.rb
index ffbc374b..f54b1d27 100644
--- a/app/models/activity_pub/activity/flag.rb
+++ b/app/models/activity_pub/activity/flag.rb
@@ -2,6 +2,16 @@
class ActivityPub
class Activity
- class Flag < ActivityPub::Activity; end
+ class Flag < ActivityPub::Activity
+ # Notifica a todes les moderadores
+ def update_activity_pub_state!
+ ActivityPub::ActorFlaggedMailer.with(
+ content: content['content'],
+ object: content['object'],
+ actor: content['actor'],
+ site_id: activity_pub.site_id
+ ).notify_moderators.deliver_later
+ end
+ end
end
end
diff --git a/app/models/concerns/activity_pub/moderator_concern.rb b/app/models/concerns/activity_pub/moderator_concern.rb
new file mode 100644
index 00000000..ed0cf245
--- /dev/null
+++ b/app/models/concerns/activity_pub/moderator_concern.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class ActivityPub
+ module ModeratorConcern
+ extend ActiveSupport::Concern
+
+ included do
+ scope :moderators, ->() { where(moderator: true) }
+ end
+ end
+end
diff --git a/app/models/concerns/metadata/unused_values_concern.rb b/app/models/concerns/metadata/unused_values_concern.rb
new file mode 100644
index 00000000..a6f8aa54
--- /dev/null
+++ b/app/models/concerns/metadata/unused_values_concern.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Metadata
+ # Hasta ahora veníamos habilitando la opción de romper
+ # retroactivamente relaciones, sin informar que estaba sucediendo.
+ # Con este módulo, todas las relaciones que ya tienen una relación
+ # inversa son ignoradas.
+ module UnusedValuesConcern
+ extend ActiveSupport::Concern
+
+ included do
+ # Excluye el Post actual y todos los que ya tengan una relación
+ # inversa, para no romperla.
+ #
+ # @return [Array]
+ def values
+ @values ||= posts.map do |p|
+ next if p.uuid.value == post.uuid.value
+
+ disabled = false
+
+ # El campo está deshabilitado si está completo y no incluye el
+ # post actual.
+ if inverse?
+ disabled = p[inverse].present? && ![p[inverse].value].flatten.include?(post.uuid.value)
+ end
+
+ [title(p), p.uuid.value, disabled]
+ end.compact
+ end
+ end
+ end
+end
diff --git a/app/models/deploy.rb b/app/models/deploy.rb
index 77646034..b9d6c9d3 100644
--- a/app/models/deploy.rb
+++ b/app/models/deploy.rb
@@ -10,7 +10,7 @@ require 'open3'
# :attributes`.
class Deploy < ApplicationRecord
belongs_to :site
- belongs_to :rol
+ belongs_to :rol, optional: true
has_many :build_stats, dependent: :destroy
diff --git a/app/models/deploy_alternative_domain.rb b/app/models/deploy_alternative_domain.rb
index 75b69180..293b032b 100644
--- a/app/models/deploy_alternative_domain.rb
+++ b/app/models/deploy_alternative_domain.rb
@@ -2,7 +2,7 @@
# Soportar dominios alternativos
class DeployAlternativeDomain < Deploy
- store :values, accessors: %i[hostname], coder: JSON
+ store_accessor :values, :hostname
DEPENDENCIES = %i[deploy_local]
diff --git a/app/models/deploy_distributed_press.rb b/app/models/deploy_distributed_press.rb
index 95f311e4..867ed4ac 100644
--- a/app/models/deploy_distributed_press.rb
+++ b/app/models/deploy_distributed_press.rb
@@ -12,7 +12,10 @@ require 'distributed_press/v1/client/site'
# Al ser publicado, envía los archivos en un tarball y actualiza la
# información.
class DeployDistributedPress < Deploy
- store :values, accessors: %i[hostname remote_site_id remote_info distributed_press_publisher_id], coder: JSON
+ store_accessor :values, :hostname
+ store_accessor :values, :remote_site_id
+ store_accessor :values, :remote_info
+ store_accessor :values, :distributed_press_publisher_id
before_create :create_remote_site!
before_destroy :delete_remote_site!
@@ -23,7 +26,7 @@ class DeployDistributedPress < Deploy
#
# @param :output [Bool]
# @return [Bool]
- def deploy
+ def deploy(output: true)
status = false
log = []
diff --git a/app/models/deploy_hidden_service.rb b/app/models/deploy_hidden_service.rb
index 25c0c217..b558edba 100644
--- a/app/models/deploy_hidden_service.rb
+++ b/app/models/deploy_hidden_service.rb
@@ -2,7 +2,7 @@
# Genera una versión onion
class DeployHiddenService < DeployWww
- store :values, accessors: %i[onion], coder: JSON
+ store_accessor :values, :onion
before_create :create_hidden_service!
diff --git a/app/models/deploy_local.rb b/app/models/deploy_local.rb
index 29a31f8c..36f06644 100644
--- a/app/models/deploy_local.rb
+++ b/app/models/deploy_local.rb
@@ -3,14 +3,12 @@
# Alojamiento local, solo genera el sitio, con lo que no necesita hacer
# nada más
class DeployLocal < Deploy
- store :values, accessors: %i[], coder: JSON
-
before_destroy :remove_destination!
def bundle(output: false)
run %(bundle config set --local clean 'true'), output: output
- run(%(bundle config set --local deployment 'true'), output: output) if site.gemfile_lock_path?
- run %(bundle config set --local path '#{gems_dir}'), output: output
+ run %(bundle config set --local deployment 'true'), output: output if site.gemfile_lock_path?
+ run %(bundle config set --local path '#{site.bundle_path}'), output: output
run %(bundle config set --local without 'test development'), output: output
run %(bundle config set --local cache_all 'false'), output: output
run %(bundle install), output: output
diff --git a/app/models/deploy_localized_domain.rb b/app/models/deploy_localized_domain.rb
index 59e17dcd..5a8c1689 100644
--- a/app/models/deploy_localized_domain.rb
+++ b/app/models/deploy_localized_domain.rb
@@ -2,7 +2,8 @@
# Soportar dominios localizados
class DeployLocalizedDomain < DeployAlternativeDomain
- store :values, accessors: %i[hostname locale], coder: JSON
+ store_accessor :values, :hostname
+ store_accessor :values, :locale
# Generar un link simbólico del sitio principal al alternativo
def deploy(**)
diff --git a/app/models/deploy_reindex.rb b/app/models/deploy_reindex.rb
index f3eb3d23..ddc20ec9 100644
--- a/app/models/deploy_reindex.rb
+++ b/app/models/deploy_reindex.rb
@@ -2,7 +2,9 @@
# Reindexa los artículos al terminar la compilación
class DeployReindex < Deploy
- def deploy(**)
+ def deploy(output: true)
+ puts 'Reindex' if output
+
time_start
site.reset
diff --git a/app/models/deploy_rsync.rb b/app/models/deploy_rsync.rb
index fcc5a65d..a9fdf991 100644
--- a/app/models/deploy_rsync.rb
+++ b/app/models/deploy_rsync.rb
@@ -3,11 +3,15 @@
# Sincroniza sitios a servidores remotos usando Rsync. El servidor
# remoto tiene que tener rsync instalado.
class DeployRsync < Deploy
- store :values, accessors: %i[hostname destination host_keys], coder: JSON
+ store_accessor :values, :hostname
+ store_accessor :values, :destination
+ store_accessor :values, :host_keys
DEPENDENCIES = %i[deploy_local deploy_zip]
def deploy(output: false)
+ raise(ArgumentError, 'destination no está configurado') if destination.blank?
+
ssh? && rsync(output: output)
end
@@ -18,13 +22,6 @@ class DeployRsync < Deploy
deploy_local.size
end
- # Devolver el destino o lanzar un error si no está configurado
- def destination
- values[:destination].tap do |d|
- raise(ArgumentError, 'destination no está configurado') if d.blank?
- end
- end
-
# @return [String]
def url
"https://#{hostname}/"
@@ -43,9 +40,9 @@ class DeployRsync < Deploy
ssh_available = false
Net::SSH.start(host, user, verify_host_key: tofu, timeout: 5) do |ssh|
- if values[:host_keys].blank?
+ if self.host_keys.blank?
# Guardar las llaves que se encontraron en la primera conexión
- values[:host_keys] = ssh.transport.host_keys.map do |host_key|
+ self.host_keys = ssh.transport.host_keys.map do |host_key|
"#{host_key.ssh_type} #{host_key.fingerprint}"
end
@@ -74,14 +71,14 @@ class DeployRsync < Deploy
#
# @return [Symbol]
def tofu
- values[:host_keys].present? ? :always : :accept_new
+ self.host_keys.present? ? :always : :accept_new
end
# Devuelve el par user host
#
# @return [Array]
def user_host
- destination.split(':', 2).first.split('@', 2).tap do |d|
+ @user_host ||= destination.split(':', 2).first.split('@', 2).tap do |d|
next unless d.size == 1
d.insert(0, nil)
diff --git a/app/models/deploy_www.rb b/app/models/deploy_www.rb
index bb25cc64..aafb518f 100644
--- a/app/models/deploy_www.rb
+++ b/app/models/deploy_www.rb
@@ -2,8 +2,6 @@
# Vincula la versión del sitio con www a la versión sin
class DeployWww < Deploy
- store :values, accessors: %i[], coder: JSON
-
DEPENDENCIES = %i[deploy_local]
before_destroy :remove_destination!
diff --git a/app/models/deploy_zip.rb b/app/models/deploy_zip.rb
index 85005470..9f538f3e 100644
--- a/app/models/deploy_zip.rb
+++ b/app/models/deploy_zip.rb
@@ -6,8 +6,6 @@ require 'zip'
#
# TODO: Firmar con minisign
class DeployZip < Deploy
- store :values, accessors: %i[], coder: JSON
-
DEPENDENCIES = %i[deploy_local]
# Una vez que el sitio está generado, tomar todos los archivos y
diff --git a/app/models/metadata_array.rb b/app/models/metadata_array.rb
index 368aa546..a8a6e555 100644
--- a/app/models/metadata_array.rb
+++ b/app/models/metadata_array.rb
@@ -19,8 +19,12 @@ class MetadataArray < MetadataTemplate
true && !private?
end
+ def titleize?
+ true
+ end
+
def to_s
- value.join(', ')
+ value.select(&:present?).join(', ')
end
# Obtiene el valor desde el documento, convirtiéndolo a Array si no lo
diff --git a/app/models/metadata_belongs_to.rb b/app/models/metadata_belongs_to.rb
index be1fa670..f527a908 100644
--- a/app/models/metadata_belongs_to.rb
+++ b/app/models/metadata_belongs_to.rb
@@ -13,6 +13,10 @@ class MetadataBelongsTo < MetadataRelatedPosts
''
end
+ def to_s
+ belongs_to.try(:title).try(:value).to_s
+ end
+
# Obtiene el valor desde el documento.
#
# @return [String]
@@ -20,14 +24,6 @@ class MetadataBelongsTo < MetadataRelatedPosts
document.data[name.to_s]
end
- def validate
- super
-
- errors << I18n.t('metadata.belongs_to.missing_post') unless post_exists?
-
- errors.empty?
- end
-
# Guardar y guardar la relación inversa también, eliminando la
# relación anterior si existía.
def save
@@ -40,13 +36,23 @@ class MetadataBelongsTo < MetadataRelatedPosts
# Si estamos cambiando la relación, tenemos que eliminar la relación
# anterior
if belonged_to.present?
- belonged_to[inverse].value = belonged_to[inverse].value.reject do |rej|
- rej == post.uuid.value
+ if belonged_to[inverse].respond_to? :has_one
+ belonged_to[inverse].value = ''
+ else
+ belonged_to[inverse].value = belonged_to[inverse].value.reject do |rej|
+ rej == post.uuid.value
+ end
end
end
# No duplicar las relaciones
- belongs_to[inverse].value = (belongs_to[inverse].value.dup << post.uuid.value) unless belongs_to.blank? || included?
+ if belongs_to.present?
+ if belongs_to[inverse].respond_to? :has_one
+ belongs_to[inverse].value = post.uuid.value
+ else
+ belongs_to[inverse].value = (belongs_to[inverse].value.dup << post.uuid.value) unless belongs_to.blank? || included?
+ end
+ end
true
end
@@ -97,6 +103,6 @@ class MetadataBelongsTo < MetadataRelatedPosts
end
def sanitize(uuid)
- uuid.to_s.gsub(/[^a-f0-9\-]/i, '')
+ uuid.to_s.gsub(/[^a-f0-9-]/i, '')
end
end
diff --git a/app/models/metadata_content.rb b/app/models/metadata_content.rb
index 444ee2fe..0fc32221 100644
--- a/app/models/metadata_content.rb
+++ b/app/models/metadata_content.rb
@@ -58,8 +58,13 @@ class MetadataContent < MetadataTemplate
uri = URI element['src']
- # No permitimos recursos externos
- raise URI::Error unless Rails.application.config.hosts.include?(uri.hostname)
+ # No permitimos recursos externos, solo si sabemos cuales son
+ # los recursos locales
+ if Rails.application.config.hosts.present?
+ unless Rails.application.config.hosts.include?(uri.hostname)
+ raise URI::Error
+ end
+ end
element['src'] = convert_src_to_internal_path uri
diff --git a/app/models/metadata_date.rb b/app/models/metadata_date.rb
index 9e655d4c..5a96871a 100644
--- a/app/models/metadata_date.rb
+++ b/app/models/metadata_date.rb
@@ -1,8 +1,30 @@
# frozen_string_literal: true
class MetadataDate < MetadataTemplate
+ # La fecha de hoy si no hay nada. Podemos traer un valor por defecto
+ # desde el esquema, siempre y cuando pueda considerarse una fecha
+ # válida.
+ #
+ # @return [Date,nil]
def default_value
- Date.today
+ if (dv = super.presence)
+ begin
+ Date.parse(dv)
+ # XXX: Notificar para que sepamos que el esquema no es válido.
+ # TODO: Validar el valor por defecto en sutty-schema-validator.
+ rescue Date::Error => e
+ ExceptionNotifier.notify_exception(e, data: { site: site.name, post: post.id, name:, type: })
+ nil
+ end
+ end
+ end
+
+ # Delegar el formato al valor, para uso dentro de date_field()
+ #
+ # @param format [String]
+ # @return [String,nil]
+ def strftime(format)
+ value&.strftime(format)
end
# Devuelve una fecha, si no hay ninguna es la fecha de hoy.
diff --git a/app/models/metadata_file.rb b/app/models/metadata_file.rb
index 3ac89c9b..fb5082f4 100644
--- a/app/models/metadata_file.rb
+++ b/app/models/metadata_file.rb
@@ -18,6 +18,18 @@ class MetadataFile < MetadataTemplate
# XXX: Esto ayuda a deserializar en {Site#everything_of}
def values; end
+ # Usar la descripción
+ def titleize?
+ true
+ end
+
+ # Devolver la descripción
+ #
+ # @return [String]
+ def to_s
+ value['description'].to_s
+ end
+
def validate
super
@@ -49,6 +61,8 @@ class MetadataFile < MetadataTemplate
value['path'] = relative_destination_path_with_filename.to_s if static_file
end
+ self[:value] = self[:value].to_h
+
true
end
diff --git a/app/models/metadata_geo.rb b/app/models/metadata_geo.rb
index ba11f337..d475edf9 100644
--- a/app/models/metadata_geo.rb
+++ b/app/models/metadata_geo.rb
@@ -14,7 +14,7 @@ class MetadataGeo < MetadataTemplate
return true unless changed?
return true if empty?
- self[:value] = value.transform_values(&:to_f)
+ self[:value] = value.transform_values(&:to_f).to_h
self[:value] = encrypt(value) if private?
true
diff --git a/app/models/metadata_has_many.rb b/app/models/metadata_has_many.rb
index 13f0dcf5..a15f1241 100644
--- a/app/models/metadata_has_many.rb
+++ b/app/models/metadata_has_many.rb
@@ -36,10 +36,15 @@ class MetadataHasMany < MetadataRelatedPosts
def save
super
+ self[:value] = self[:value].uniq
+
return true unless changed?
return true unless inverse?
(had_many - has_many).each do |remove|
+ # No modificar nada si la relación ya estaba deshecha
+ next unless remove[inverse]&.value == post.uuid.value
+
remove[inverse]&.value = remove[inverse].default_value
end
diff --git a/app/models/metadata_has_one.rb b/app/models/metadata_has_one.rb
new file mode 100644
index 00000000..d1705e7d
--- /dev/null
+++ b/app/models/metadata_has_one.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+class MetadataHasOne < MetadataBelongsTo
+ alias has_one belongs_to
+ alias had_one belonged_to
+
+ def save
+ # XXX: DRY
+ if !changed?
+ self[:value] = document_value
+
+ return true
+ end
+
+ self[:value] = sanitize value
+
+ return true unless changed?
+ return true unless inverse?
+
+ had_one[inverse]&.value = '' if had_one
+ has_one[inverse]&.value = post.uuid.value if has_one
+
+ true
+ end
+
+ def related_methods
+ @related_methods ||= %i[has_one had_one].freeze
+ end
+end
diff --git a/app/models/metadata_has_one_nested.rb b/app/models/metadata_has_one_nested.rb
new file mode 100644
index 00000000..2f3e8e02
--- /dev/null
+++ b/app/models/metadata_has_one_nested.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class MetadataHasOneNested < MetadataHasOne
+ def nested
+ @nested ||= layout.metadata.dig(name, 'nested')
+ end
+
+ def nested?
+ true
+ end
+
+ # No tener conflictos con related
+ def related_methods
+ @related_methods ||= [].freeze
+ end
+end
diff --git a/app/models/metadata_new_array.rb b/app/models/metadata_new_array.rb
new file mode 100644
index 00000000..65993be2
--- /dev/null
+++ b/app/models/metadata_new_array.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+# Implementa la nueva interfaz de gestión de valores
+class MetadataNewArray < MetadataArray
+end
diff --git a/app/models/metadata_new_belongs_to.rb b/app/models/metadata_new_belongs_to.rb
new file mode 100644
index 00000000..46d25ce6
--- /dev/null
+++ b/app/models/metadata_new_belongs_to.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+# Nueva interfaz
+class MetadataNewBelongsTo < MetadataBelongsTo
+ include Metadata::UnusedValuesConcern
+end
diff --git a/app/models/metadata_new_has_and_belongs_to_many.rb b/app/models/metadata_new_has_and_belongs_to_many.rb
new file mode 100644
index 00000000..44102ec0
--- /dev/null
+++ b/app/models/metadata_new_has_and_belongs_to_many.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+# Nueva interfaz para relaciones muchos a muchos
+class MetadataNewHasAndBelongsToMany < MetadataHasAndBelongsToMany; end
diff --git a/app/models/metadata_new_has_many.rb b/app/models/metadata_new_has_many.rb
new file mode 100644
index 00000000..e4b3869c
--- /dev/null
+++ b/app/models/metadata_new_has_many.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+# Interfaz nueva para uno a muchos
+class MetadataNewHasMany < MetadataHasMany
+ include Metadata::UnusedValuesConcern
+end
diff --git a/app/models/metadata_new_has_one.rb b/app/models/metadata_new_has_one.rb
new file mode 100644
index 00000000..b75b14ae
--- /dev/null
+++ b/app/models/metadata_new_has_one.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+# Nueva interfaz para relaciones 1:1
+class MetadataNewHasOne < MetadataHasOne; end
diff --git a/app/models/metadata_new_predefined_array.rb b/app/models/metadata_new_predefined_array.rb
new file mode 100644
index 00000000..8c15155f
--- /dev/null
+++ b/app/models/metadata_new_predefined_array.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+# Nueva interfaz para arrays predefinidos
+class MetadataNewPredefinedArray < MetadataPredefinedArray; end
diff --git a/app/models/metadata_new_predefined_value.rb b/app/models/metadata_new_predefined_value.rb
new file mode 100644
index 00000000..929934b2
--- /dev/null
+++ b/app/models/metadata_new_predefined_value.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+# Nueva interfaz
+class MetadataNewPredefinedValue < MetadataPredefinedValue
+ def values
+ @values ||= (required ? {} : { I18n.t('posts.attributes.new_predefined_value.empty') => '' }).merge(super)
+ end
+end
diff --git a/app/models/metadata_permalink.rb b/app/models/metadata_permalink.rb
index 895b7439..935202c9 100644
--- a/app/models/metadata_permalink.rb
+++ b/app/models/metadata_permalink.rb
@@ -7,12 +7,25 @@ class MetadataPermalink < MetadataString
false
end
+ # Devuelve la URL actual del sitio
+ #
+ # @return [String, nil]
+ def default_value
+ if post.written?
+ super.presence || document.url
+ else
+ super.presence
+ end
+ end
+
private
# Al hacer limpieza, validamos la ruta. Eliminamos / multiplicadas,
# puntos suspensivos, la primera / para que siempre sea relativa y
# agregamos una / al final si la ruta no tiene extensión.
def sanitize(value)
+ return value.strip if value.blank?
+
value = value.strip.unicode_normalize.gsub('..', '/').gsub('./', '').squeeze('/')
value = value[1..-1] if value.start_with? '/'
value += '/' if File.extname(value).blank?
diff --git a/app/models/metadata_plain_text.rb b/app/models/metadata_plain_text.rb
new file mode 100644
index 00000000..e12ee18b
--- /dev/null
+++ b/app/models/metadata_plain_text.rb
@@ -0,0 +1,3 @@
+# frozen_string_literal: true
+
+class MetadataPlainText < MetadataContent; end
diff --git a/app/models/metadata_predefined_array.rb b/app/models/metadata_predefined_array.rb
index b8e5050e..566a491b 100644
--- a/app/models/metadata_predefined_array.rb
+++ b/app/models/metadata_predefined_array.rb
@@ -7,4 +7,13 @@ class MetadataPredefinedArray < MetadataArray
[v[I18n.locale.to_s], k]
end&.to_h
end
+
+ # Devolver los valores legibles por humanes
+ #
+ # @todo Debería devolver los valores en el idioma del post, no de le
+ # usuarie
+ # @return [String]
+ def to_s
+ values.invert.select { |x, k| value.include?(x) }.values.join(', ')
+ end
end
diff --git a/app/models/metadata_predefined_value.rb b/app/models/metadata_predefined_value.rb
index 9cf36382..dbdb1e48 100644
--- a/app/models/metadata_predefined_value.rb
+++ b/app/models/metadata_predefined_value.rb
@@ -10,6 +10,10 @@ class MetadataPredefinedValue < MetadataString
@values ||= layout.dig(:metadata, name, 'values', I18n.locale.to_s)&.invert || {}
end
+ def to_s
+ values.invert[value].to_s
+ end
+
private
# Solo permite almacenar los valores predefinidos.
diff --git a/app/models/metadata_related_posts.rb b/app/models/metadata_related_posts.rb
index 42d1381b..6ca09f7d 100644
--- a/app/models/metadata_related_posts.rb
+++ b/app/models/metadata_related_posts.rb
@@ -22,10 +22,21 @@ class MetadataRelatedPosts < MetadataArray
false
end
+ def titleize?
+ false
+ end
+
def indexable_values
posts.where(uuid: value).map(&:title).map(&:value)
end
+ # Encuentra el filtro
+ #
+ # @return [Hash]
+ def filter
+ layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {}
+ end
+
private
# Obtiene todos los posts y opcionalmente los filtra
@@ -34,17 +45,12 @@ class MetadataRelatedPosts < MetadataArray
end
def title(post)
- "#{post&.title&.value || post&.slug&.value} #{post&.date&.value.strftime('%F')} (#{post.layout.humanized_name})"
- end
-
- # Encuentra el filtro
- def filter
- layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {}
+ "#{post&.title&.value || post&.slug&.value} #{post&.date&.value&.strftime('%F')} (#{post.layout.humanized_name})"
end
def sanitize(uuid)
super(uuid.map do |u|
- u.to_s.gsub(/[^a-f0-9\-]/i, '')
+ u.to_s.gsub(/[^a-f0-9-]/i, '')
end)
end
end
diff --git a/app/models/metadata_slug.rb b/app/models/metadata_slug.rb
index b0fe8cec..417cfa0b 100644
--- a/app/models/metadata_slug.rb
+++ b/app/models/metadata_slug.rb
@@ -25,7 +25,7 @@ require 'jekyll/utils'
class MetadataSlug < MetadataTemplate
# Trae el slug desde el título si existe o una string al azar
def default_value
- title ? Jekyll::Utils.slugify(title, mode: site.slugify_mode) : SecureRandom.uuid
+ Jekyll::Utils.slugify(title || SecureRandom.uuid, mode: site.slugify_mode)
end
def value
diff --git a/app/models/metadata_string.rb b/app/models/metadata_string.rb
index c1d888b1..cb3ad264 100644
--- a/app/models/metadata_string.rb
+++ b/app/models/metadata_string.rb
@@ -11,6 +11,10 @@ class MetadataString < MetadataTemplate
true && !private?
end
+ def titleize?
+ true
+ end
+
private
# No se permite HTML en las strings
diff --git a/app/models/metadata_template.rb b/app/models/metadata_template.rb
index 78989e15..29391ac6 100644
--- a/app/models/metadata_template.rb
+++ b/app/models/metadata_template.rb
@@ -12,6 +12,15 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
false
end
+ def nested?
+ false
+ end
+
+ # El valor puede ser parte de un título auto-generado
+ def titleize?
+ false
+ end
+
def inspect
"#<#{self.class} site=#{site.name.inspect} post=#{post.id.inspect} value=#{value.inspect}>"
end
@@ -38,18 +47,10 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
"#{cache_key}-#{cache_version}"
end
- # XXX: Deberíamos sanitizar durante la asignación?
- def value=(new_value)
- @value_was = value
- self[:value] = new_value
- end
-
# Siempre obtener el valor actual y solo obtenerlo del documento una
# vez.
def value_was
- return @value_was if instance_variable_defined? '@value_was'
-
- @value_was = document_value
+ @value_was ||= document_value.nil? ? default_value : document_value
end
def changed?
@@ -169,7 +170,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
# once => el campo solo se puede modificar si estaba vacío
def writable?
case layout.metadata.dig(name, 'writable')
- when 'once' then value.blank?
+ when 'once' then value_was.blank?
else true
end
end
diff --git a/app/models/metadata_text.rb b/app/models/metadata_text.rb
index 103bcd0a..3ac336bb 100644
--- a/app/models/metadata_text.rb
+++ b/app/models/metadata_text.rb
@@ -1,4 +1,8 @@
# frozen_string_literal: true
# Un campo de texto largo
-class MetadataText < MetadataString; end
+class MetadataText < MetadataString
+ def titleize?
+ false
+ end
+end
diff --git a/app/models/metadata_title.rb b/app/models/metadata_title.rb
new file mode 100644
index 00000000..6d6b1bde
--- /dev/null
+++ b/app/models/metadata_title.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+# El título es obligatorio para todos los Post, si el esquema no lo
+# incluye, tenemos que poder generar un valor legible por humanes.
+class MetadataTitle < MetadataString
+ def titleize?
+ false
+ end
+
+ # Siempre recalcular el título
+ def value
+ self[:value] = default_value
+ end
+
+ # Obtener todos los valores de texto del artículo y generar un título
+ # en base a eso.
+ #
+ # @return [String]
+ def default_value
+ post.attributes.select do |attr|
+ post[attr].titleize?
+ end.map do |attr|
+ post[attr].to_s
+ end.compact.join(' ').strip.squeeze(' ')
+ end
+end
diff --git a/app/models/post.rb b/app/models/post.rb
index 8885897f..a1f48219 100644
--- a/app/models/post.rb
+++ b/app/models/post.rb
@@ -12,9 +12,24 @@ class Post
DEFAULT_ATTRIBUTES = %i[site document layout].freeze
# Otros atributos que no vienen en los metadatos
PRIVATE_ATTRIBUTES = %i[path slug attributes errors].freeze
- PUBLIC_ATTRIBUTES = %i[lang date uuid created_at].freeze
+ PUBLIC_ATTRIBUTES = %i[title lang date uuid created_at].freeze
+ ALIASED_ATTRIBUTES = %i[locale].freeze
ATTR_SUFFIXES = %w[? =].freeze
+ ATTRIBUTE_DEFINITIONS = {
+ 'title' => { 'type' => 'title', 'required' => true },
+ 'lang' => { 'type' => 'lang', 'required' => true },
+ 'date' => { 'type' => 'document_date', 'required' => true },
+ 'uuid' => { 'type' => 'uuid', 'required' => true },
+ 'created_at' => { 'type' => 'created_at', 'required' => true },
+ 'slug' => { 'type' => 'slug', 'required' => true },
+ 'path' => { 'type' => 'path', 'required' => true },
+ 'locale' => { 'alias' => 'lang' }
+ }.freeze
+
+ class PostError < StandardError; end
+ class UnknownAttributeError < PostError; end
+
attr_reader :attributes, :errors, :layout, :site, :document
# TODO: Modificar el historial de Git con callbacks en lugar de
@@ -30,6 +45,10 @@ class Post
# a demanda?
def find_layout(path)
File.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym
+ rescue Errno::ENOENT => e
+ ExceptionNotifier.notify(e)
+
+ :post
end
end
@@ -46,16 +65,39 @@ class Post
@layout = args[:layout]
@site = args[:site]
@document = args[:document]
- @attributes = layout.attributes + PUBLIC_ATTRIBUTES
+ @attributes = (layout.attributes + PUBLIC_ATTRIBUTES).uniq
@errors = {}
@metadata = {}
- # Inicializar valores
+ layout.metadata = ATTRIBUTE_DEFINITIONS.merge(layout.metadata).with_indifferent_access
+
+ # Leer el documento si existe
+ # @todo Asignar todos los valores a self[:value] luego de leer
+ document&.read! unless new?
+
+ # Inicializar valores o modificar los que vengan del documento
+ assignable_attributes = args.slice(*attributes)
+ assign_attributes(assignable_attributes) if assignable_attributes.present?
+ end
+
+ # Asignar atributos, ignorando atributos que no se pueden modificar
+ # o inexistentes
+ #
+ # @param attrs [Hash]
+ def assign_attributes(attrs)
+ attrs = attrs.transform_keys(&:to_sym)
+
attributes.each do |attr|
- public_send(attr)&.value = args[attr] if args.key?(attr)
+ self[attr].value = attrs[attr] if attrs.key?(attr) && self[attr].writable?
end
- document.read! unless new?
+ unknown_attrs = attrs.keys.map(&:to_sym) - attributes
+
+ if unknown_attrs.present?
+ raise UnknownAttributeError, "Unknown attribute(s) #{unknown_attrs.map(&:to_s).join(', ')} for Post"
+ end
+
+ nil
end
def inspect
@@ -103,6 +145,7 @@ class Post
src = element.attributes['src']
next unless src&.value&.start_with? 'public/'
+
file = MetadataFile.new(site: site, post: self, document: document, layout: layout)
file.value['path'] = src.value
@@ -164,14 +207,13 @@ class Post
def method_missing(name, *_args)
# Limpiar el nombre del atributo, para que todos los ayudantes
# reciban el método en limpio
- unless attribute? name
- raise NoMethodError, I18n.t('exceptions.post.no_method',
- method: name)
- end
+ raise NoMethodError, I18n.t('exceptions.post.no_method', method: name) unless attribute? name
define_singleton_method(name) do
template = layout.metadata[name.to_s]
+ return public_send(template['alias'].to_sym) if template.key?('alias')
+
@metadata[name] ||=
MetadataFactory.build(document: document,
post: self,
@@ -187,55 +229,6 @@ class Post
public_send name
end
- # TODO: Mover a method_missing
- def slug
- @metadata[:slug] ||= MetadataSlug.new(document: document, site: site, layout: layout, name: :slug, type: :slug,
- post: self, required: true)
- end
-
- # TODO: Mover a method_missing
- def date
- @metadata[:date] ||= MetadataDocumentDate.new(document: document, site: site, layout: layout, name: :date,
- type: :document_date, post: self, required: true)
- end
-
- # TODO: Mover a method_missing
- def path
- @metadata[:path] ||= MetadataPath.new(document: document, site: site, layout: layout, name: :path, type: :path,
- post: self, required: true)
- end
-
- # TODO: Mover a method_missing
- def lang
- @metadata[:lang] ||= MetadataLang.new(document: document, site: site, layout: layout, name: :lang, type: :lang,
- post: self, required: true)
- end
-
- alias locale lang
-
- # TODO: Mover a method_missing
- def uuid
- @metadata[:uuid] ||= MetadataUuid.new(document: document, site: site, layout: layout, name: :uuid, type: :uuid,
- post: self, required: true)
- end
-
- # La fecha de creación inmodificable del post
- def created_at
- @metadata[:created_at] ||= MetadataCreatedAt.new(document: document, site: site, layout: layout, name: :created_at, type: :created_at, post: self, required: true)
- end
-
- # Detecta si es un atributo válido o no, a partir de la tabla de la
- # plantilla
- def attribute?(mid)
- included = DEFAULT_ATTRIBUTES.include?(mid) ||
- PRIVATE_ATTRIBUTES.include?(mid) ||
- PUBLIC_ATTRIBUTES.include?(mid)
-
- included = attributes.include? mid if !included && singleton_class.method_defined?(:attributes)
-
- included
- end
-
# Devuelve los strong params para el layout.
#
# XXX: Nos gustaría no tener que instanciar Metadata acá, pero depende
@@ -386,11 +379,7 @@ class Post
end
def update_attributes(hashable)
- hashable.to_hash.each do |attr, value|
- next unless self[attr].writable?
-
- self[attr].value = value
- end
+ assign_attributes(hashable)
save
end
@@ -404,6 +393,38 @@ class Post
@usuaries ||= document_usuaries.empty? ? [] : Usuarie.where(id: document_usuaries).to_a
end
+ # Todos los atributos anidados
+ #
+ # @return [Array]
+ def nested_attributes
+ @nested_attributes ||= attributes.map { |a| self[a] }.select(&:nested?).map(&:name)
+ end
+
+ # Detecta si es un atributo válido o no, a partir de la tabla de la
+ # plantilla
+ def attribute?(mid)
+ included = DEFAULT_ATTRIBUTES.include?(mid) ||
+ PRIVATE_ATTRIBUTES.include?(mid) ||
+ PUBLIC_ATTRIBUTES.include?(mid) ||
+ ALIASED_ATTRIBUTES.include?(mid)
+
+ included = attributes.include? mid if !included && singleton_class.method_defined?(:attributes)
+
+ included
+ end
+
+ # Devuelve la URL absoluta
+ #
+ # @return [String, nil]
+ def absolute_url
+ return unless written?
+
+ @absolute_url ||=
+ URI.parse(site.url).tap do |uri|
+ uri.path = document.url
+ end.to_s
+ end
+
private
# Levanta un error si al construir el artículo no pasamos un atributo.
diff --git a/app/models/site.rb b/app/models/site.rb
index ea49e147..d8fc1fe8 100644
--- a/app/models/site.rb
+++ b/app/models/site.rb
@@ -68,8 +68,7 @@ class Site < ApplicationRecord
before_create :clone_skel!
# Elimina el directorio al destruir un sitio
before_destroy :remove_directories!
- # Cambiar el nombre del directorio
- before_update :update_name!
+
before_save :add_private_key_if_missing!
# Guardar la configuración si hubo cambios
after_save :sync_attributes_with_config!
@@ -234,7 +233,9 @@ class Site < ApplicationRecord
# colecciones.
def collections
unless @read
- jekyll.reader.read_collections
+ Site.one_at_a_time.synchronize do
+ jekyll.reader.read_collections
+ end
@read = true
end
@@ -434,7 +435,9 @@ class Site < ApplicationRecord
# Si estamos usando nuestro propio plugin de i18n, los posts están
# en "colecciones"
locales.map(&:to_s).each do |i|
- @configuration['collections'][i] = {}
+ @configuration['collections'][i] = {
+ 'permalink' => configuration.send(:style_to_permalink, configuration['permalink'])
+ }
end
@configuration
@@ -504,12 +507,6 @@ class Site < ApplicationRecord
FileUtils.rm_rf path
end
- def update_name!
- return unless name_changed?
-
- FileUtils.mv path_was, path
- reload_jekyll!
- end
# Sincroniza algunos atributos del sitio con su configuración y
# guarda los cambios
@@ -589,17 +586,34 @@ class Site < ApplicationRecord
# * El archivo Gemfile.lock se modificó
def install_gems
return unless persisted?
+ return unless (!gems_installed? || theme_path.blank?) || gemfile_updated? || gemfile_lock_updated?
- deploy_local = deploys.find_by_type('DeployLocal')
- deploy_local.git_lfs
-
- return unless !gems_installed? || gemfile_updated? || gemfile_lock_updated?
-
- deploy_local.bundle
+ deploys.find_by_type('DeployLocal').bundle
touch
FileUtils.touch(gemfile_path)
end
+ # El sitio tiene una plantilla
+ #
+ # @return [Bool]
+ def theme?
+ config['theme'].present?
+ end
+
+ # El directorio donde se encuentran los archivos de la plantilla. Si
+ # es nil es que las dependencias todavía no se instalaron.
+ #
+ # @return [String,nil]
+ def theme_path
+ @theme_path ||=
+ if theme?
+ Dir[gem_path.join('gems', "#{config['theme']}-*").to_s].first
+ else
+ path
+ end
+ end
+
+ # @return [Pathname]
def gem_path
@gem_path ||=
begin
diff --git a/app/models/site/index.rb b/app/models/site/index.rb
index cfa4030a..1e256a9e 100644
--- a/app/models/site/index.rb
+++ b/app/models/site/index.rb
@@ -46,11 +46,19 @@ class Site
private
- # Trae el último commit indexado desde el repositorio
+ # Trae el último commit indexado desde el repositorio, o si no
+ # existe, trae el primer commit.
#
# @return [Rugged::Commit]
def indexed_commit
- @indexed_commit ||= repository.rugged.lookup(last_indexed_commit)
+ @indexed_commit ||=
+ if repository.rugged.exists?(last_indexed_commit)
+ repository.rugged.lookup(last_indexed_commit)
+ else
+ repository.rugged.lookup(
+ repository.rugged.references[repository.rugged.head.canonical_name].log.first[:id_new]
+ )
+ end
end
# Calcula la diferencia entre el último commit indexado y el
diff --git a/app/models/site/layout_ordering.rb b/app/models/site/layout_ordering.rb
index 9fecbf21..79e53c75 100644
--- a/app/models/site/layout_ordering.rb
+++ b/app/models/site/layout_ordering.rb
@@ -13,11 +13,15 @@ class Site
# Por defecto, si el sitio no lo soporta, se obtienen los layouts
# ordenados alfabéticamente por traducción.
#
+ # @param [Usuarie,nil]
# @return [Hash]
- def schema_organization
+ def schema_organization(usuarie = nil)
@schema_organization ||=
begin
- schema_organization = data.dig('schema', 'organization')
+ # XXX: retrocompatibilidad
+ key = (usuarie.blank? || usuarie?(usuarie)) ? 'organization' : 'organization_guest'
+ schema_organization = data.dig('schema', key)
+ schema_organization ||= data.dig('schema', 'organization')
schema_organization&.symbolize_keys!
schema_organization&.transform_values! do |ary|
ary.map(&:to_sym)
diff --git a/app/models/site/repository.rb b/app/models/site/repository.rb
index 7d06fe55..581b8c87 100644
--- a/app/models/site/repository.rb
+++ b/app/models/site/repository.rb
@@ -75,13 +75,18 @@ class Site
# Forzamos el checkout para mover el HEAD al último commit y
# escribir los cambios
rugged.checkout 'HEAD', strategy: :force
-
- git_sh("git", "lfs", "fetch", "origin", default_branch)
- # reemplaza los pointers por los archivos correspondientes
- git_sh("git", "lfs", "checkout")
+
commit
end
+ # Trae todos los archivos desde LFS
+ #
+ # @return [Boolean]
+ def git_lfs_checkout
+ git_sh('git', 'lfs', 'fetch', 'origin', default_branch)
+ git_sh('git', 'lfs', 'checkout')
+ end
+
# El último commit
#
# @return [Rugged::Commit]
@@ -111,10 +116,30 @@ class Site
walker.each.to_a
end
- # Hay commits sin aplicar?
- def needs_pull?
- fetch
- !commits.empty?
+ # Detecta si hay que hacer un pull o no
+ #
+ # @return [Boolean]
+ def up_to_date?
+ rugged.merge_analysis(remote_head_commit).include?(:up_to_date)
+ end
+
+ # Detecta si es posible adelantar la historia local a la remota o
+ # necesitamos un merge
+ #
+ # @return [Boolean]
+ def fast_forward?
+ rugged.merge_analysis(remote_head_commit).include?(:fastforward)
+ end
+
+ # Mueve la historia local a la remota
+ #
+ # @see {https://stackoverflow.com/a/27077322}
+ # @return [nil]
+ def fast_forward!
+ rugged.checkout_tree(remote_head_commit)
+ rugged.references.update(rugged.head.resolve, remote_head_commit.oid)
+
+ nil
end
# Guarda los cambios en git
diff --git a/app/models/usuarie.rb b/app/models/usuarie.rb
index 4856f17f..b9a5bf94 100644
--- a/app/models/usuarie.rb
+++ b/app/models/usuarie.rb
@@ -3,6 +3,7 @@
# Usuarie de la plataforma
class Usuarie < ApplicationRecord
include Usuarie::Consent
+ include ActivityPub::ModeratorConcern
devise :invitable, :database_authenticatable,
:recoverable, :rememberable, :validatable,
diff --git a/app/services/post_service.rb b/app/services/post_service.rb
index 4631a9a4..2b7df163 100644
--- a/app/services/post_service.rb
+++ b/app/services/post_service.rb
@@ -3,25 +3,51 @@
# Este servicio se encarga de crear artículos y guardarlos en git,
# asignándoselos a une usuarie
PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
+ include AutoPublishConcern
+
+ # Si estamos pasando el UUID con los parámetros, el post quizás
+ # existe.
+ #
+ # @return [Post]
+ def create_or_update
+ uuid = params.require(base).permit(:uuid).values.first
+
+ if uuid.blank?
+ create
+ elsif (indexed_post = site.indexed_posts.find_by(post_id: uuid)).present?
+ self.post = indexed_post.post
+ update
+ else
+ create
+ end
+ end
+
# Crea un artículo nuevo
#
# @return Post
def create
- self.post = site.posts(lang: locale)
- .build(layout: layout)
- post.usuaries << usuarie
- params[:post][:draft] = true if site.invitade? usuarie
+ self.post ||= site.posts(lang: locale).build(layout: layout)
+ params[base][:draft] = true if site.invitade? usuarie
- params.require(:post).permit(:slug).tap do |p|
+ post.usuaries << usuarie
+ post.assign_attributes(post_params)
+
+ params.require(base).permit(:slug).tap do |p|
post.slug.value = p[:slug] if p[:slug].present?
end
- commit(action: :created, add: update_related_posts) if post.update(post_params)
+ # Crea los posts anidados
+ create_nested_posts! post, params[base]
+ post.save
+ update_related_posts
+
+ commit(action: :created, add: files) if post.valid?
update_site_license!
# Devolver el post aunque no se haya salvado para poder rescatar los
# errores
+ auto_publish!
post
end
@@ -34,28 +60,33 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
# Los artículos anónimos siempre son borradores
params[:draft] = true
- commit(action: :created, add: [post.path.absolute]) if post.update(anon_post_params)
+ commit(action: :created, add: files) if post.update(anon_post_params)
+ auto_publish!
post
end
def update
post.usuaries << usuarie
- params[:post][:draft] = true if site.invitade? usuarie
+ params[base][:draft] = true if site.invitade? usuarie
# Eliminar ("mover") el archivo si cambió de ubicación.
if post.update(post_params)
rm = []
rm << post.path.value_was if post.path.changed?
+ create_nested_posts! post, params[base]
+ update_related_posts
+
# Es importante que el artículo se guarde primero y luego los
# relacionados.
- commit(action: :updated, add: update_related_posts, rm: rm)
+ commit(action: :updated, add: files, rm: rm)
update_site_license!
end
# Devolver el post aunque no se haya salvado para poder rescatar los
# errores
+ auto_publish!
post
end
@@ -73,7 +104,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
#
# { uuid => 2, uuid => 1, uuid => 0 }
def reorder
- reorder = params.require(:post).permit(reorder: {})&.dig(:reorder)&.transform_values(&:to_i)
+ reorder = params.require(base).permit(reorder: {})&.dig(:reorder)&.transform_values(&:to_i)
posts = site.posts(lang: locale).where(uuid: reorder.keys)
files = posts.map do |post|
@@ -96,6 +127,22 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
private
+ # La base donde buscar los parámetros
+ #
+ # @return [Symbol]
+ def base
+ @base ||= params.permit(:base).try(:[], :base).try(:to_sym) || :post
+ end
+
+ # Una lista de archivos a modificar
+ #
+ # @return [Set]
+ def files
+ @files ||= Set.new.tap do |f|
+ f << post.path.absolute
+ end
+ end
+
def commit(action:, add: [], rm: [])
site.repository.commit(add: add,
rm: rm,
@@ -108,7 +155,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
# Solo permitir cambiar estos atributos de cada articulo
def post_params
- params.require(:post).permit(post.params)
+ @post_params ||= params.require(base).permit(post.params).to_h
end
# Eliminar metadatos internos
@@ -119,11 +166,11 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
end
def locale
- params.dig(:post, :lang)&.to_sym || I18n.locale
+ params.dig(base, :lang)&.to_sym || I18n.locale
end
def layout
- params.dig(:post, :layout) || params[:layout]
+ params.dig(base, :layout) || params[:layout]
end
# Actualiza los artículos relacionados según los métodos que los
@@ -146,15 +193,34 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
end
posts.map do |p|
- p.path.absolute if p.save(validate: false)
- end.compact << post.path.absolute
+ next unless p.save(validate: false)
+
+ files << p.path.absolute
+ end
end
# Si les usuaries modifican o crean una licencia, considerarla
# personalizada en el panel.
def update_site_license!
- if site.usuarie?(usuarie) && post.layout.name == :license && !site.licencia.custom?
- site.update licencia: Licencia.find_by_icons('custom')
+ return unless site.usuarie?(usuarie) && post.layout.name == :license && !site.licencia.custom?
+
+ site.update licencia: Licencia.find_by_icons('custom')
+ end
+
+ # Encuentra todos los posts anidados y los crea o modifica
+ def create_nested_posts!(post, params)
+ post.nested_attributes.each do |nested_attribute|
+ nested_metadata = post[nested_attribute]
+ next unless params[nested_metadata].present?
+ # @todo find_or_initialize
+ nested_post = nested_metadata.has_one || site.posts(lang: post.lang.value).build(layout: nested_metadata.nested)
+ nested_params = params.require(nested_attribute).permit(nested_post.params).to_hash
+
+ # Completa la relación 1:1
+ nested_params[nested_metadata.inverse.to_s] = post.uuid.value
+ post[nested_attribute].value = nested_post.uuid.value
+
+ files << nested_post.path.absolute if nested_post.update(nested_params)
end
end
end
diff --git a/app/services/site_service.rb b/app/services/site_service.rb
index 36868c51..84a5fcf0 100644
--- a/app/services/site_service.rb
+++ b/app/services/site_service.rb
@@ -3,6 +3,8 @@
# Se encargar de guardar cambios en sitios
# TODO: Implementar rollback en la configuración
SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
+ include AutoPublishConcern
+
def deploy
site.enqueue!
DeployJob.perform_later site
@@ -24,7 +26,9 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
#
# TODO: hacer que el repositorio se cree cuando es necesario, para
# que no haya estados intermedios.
- site.locales = [usuarie.lang] + I18n.available_locales
+ site.locales = [usuarie.lang]
+
+ add_role_to_deploys! role
add_role_to_deploys! role
@@ -36,10 +40,14 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
add_licencias &&
add_code_of_conduct &&
add_privacy_policy &&
- site.index_posts! &&
deploy
end
+ if site.persisted?
+ site.index_posts!
+ auto_publish!
+ end
+
site
end
@@ -94,6 +102,25 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
result.present?
end
+ def rename(name)
+ return if name == site.name
+
+ moved = false
+ site.name = name
+
+ Site.transaction do
+ raise ActiveRecord::Rollback if File.exist?(site.path)
+
+ FileUtils.mv(site.path_was, site.path)
+ moved = true
+ ActiveStorage::Blob.where(service_name: site.name_was).update_all(service_name: site.name)
+ site.save
+ rescue StandardError
+ FileUtils.mv(site.path, site.path_was) if moved
+ raise
+ end
+ end
+
private
# Guarda los cambios de la configuración en el repositorio git
diff --git a/app/views/activity_pub/actor_flagged_mailer/notify_moderators.text.haml b/app/views/activity_pub/actor_flagged_mailer/notify_moderators.text.haml
new file mode 100644
index 00000000..afe984f7
--- /dev/null
+++ b/app/views/activity_pub/actor_flagged_mailer/notify_moderators.text.haml
@@ -0,0 +1,18 @@
+= t('.content', site_id: params[:site_id])
+
+= Terminal::Table.new do |table|
+ - if (actor = params[:actor].presence).present?
+ - table << [ t('.actor') ]
+ - table.add_separator
+ - table << [ actor ]
+ - table.add_separator
+
+ - if (content = sanitize(params[:content])).present?
+ - table << [ word_wrap(content, line_width: 50) ]
+ - table.add_separator
+
+ - if (uris = [params[:object]].flatten).present?
+ - table << [ t('.uris') ]
+ - table.add_separator
+ - uris.each do |uri|
+ - table << [ uri ]
diff --git a/app/views/bootstrap/_alert.haml b/app/views/bootstrap/_alert.haml
index 85bcbe84..a14064ce 100644
--- a/app/views/bootstrap/_alert.haml
+++ b/app/views/bootstrap/_alert.haml
@@ -1,2 +1,2 @@
-.alert.alert-primary.mx-auto.content.max-w-md-70ch{ role: 'alert', class: local_assigns[:class] }
+.alert.alert-primary.mx-auto.content.max-w-md-70ch{ role: 'alert', **local_assigns }
= yield
diff --git a/app/views/bootstrap/_btn.haml b/app/views/bootstrap/_btn.haml
new file mode 100644
index 00000000..1bbebb26
--- /dev/null
+++ b/app/views/bootstrap/_btn.haml
@@ -0,0 +1,13 @@
+-#
+ Un botón
+
+ @param :content [String] Contenido
+ @param :action [String] Acción de Stimulus
+ @param :target [String] Objetivo de Stimulus
+ @param [Hash] Atributos en bruto, con mayor prioridad que action y target
+:ruby
+ attributes = local_assigns.to_h.except(:content)
+ attributes[:data] ||= {}
+ attributes[:data][:action] ||= local_assigns[:action]
+ attributes[:data][:target] ||= local_assigns[:target]
+%button.btn.btn-secondary{ type: 'button', **attributes }= content
diff --git a/app/views/bootstrap/_card.haml b/app/views/bootstrap/_card.haml
new file mode 100644
index 00000000..87e9691a
--- /dev/null
+++ b/app/views/bootstrap/_card.haml
@@ -0,0 +1,8 @@
+.card{ **local_assigns.except(:image, :description) }
+ - if local_assigns[:image]
+ = image_tag url_for(local_assigns[:image]), alt: local_assigns[:description], class: 'img-fluid'
+
+ .card-body
+ .card-title= title
+
+ = yield
diff --git a/app/views/bootstrap/_custom_checkbox.haml b/app/views/bootstrap/_custom_checkbox.haml
index 0c3ff3a6..dbef7516 100644
--- a/app/views/bootstrap/_custom_checkbox.haml
+++ b/app/views/bootstrap/_custom_checkbox.haml
@@ -1,6 +1,10 @@
-- help_id = "#{id}_help"
+:ruby
+ help_id = "#{id}_help"
+ checkbox_attributes = local_assigns.slice(:id, :type, :name, :value, :required, :checked, :data, :disabled)
+ checkbox_attributes[:type] ||= 'checkbox'
-.custom-control.custom-checkbox
- %input.custom-control-input{ id: id, type: 'checkbox', name: name, value: value, required: required }
+.custom-control{ class: "custom-#{checkbox_attributes[:type]}" }
+ %input.custom-control-input{ **checkbox_attributes }
%label.custom-control-label{ for: id, aria: { describedby: help_id } }= content
- %small.form-text.text-muted{ id: help_id }= yield
+ - if (block = yield).present?
+ %small.form-text.text-muted{ id: help_id }= block
diff --git a/app/views/bootstrap/_custom_checkbox_for_field.haml b/app/views/bootstrap/_custom_checkbox_for_field.haml
new file mode 100644
index 00000000..cbbc0079
--- /dev/null
+++ b/app/views/bootstrap/_custom_checkbox_for_field.haml
@@ -0,0 +1,6 @@
+- content = t("activerecord.attributes.#{field.object_name}.#{name}")
+- id = "#{field.object_name}_#{name}"
+- name = "#{field.object_name}[#{name}]"
+
+= render 'bootstrap/custom_checkbox', id: id, name: name, content: content, required: local_assigns[:required], value: "1" do
+ = yield
diff --git a/app/views/bootstrap/_modal.haml b/app/views/bootstrap/_modal.haml
new file mode 100644
index 00000000..ab8d3db3
--- /dev/null
+++ b/app/views/bootstrap/_modal.haml
@@ -0,0 +1,44 @@
+-#
+ # Modal
+
+ @see {https://getbootstrap.com/docs/4.6/components/modal/}
+ @see {https://github.com/bullet-train-co/nice_partials/issues/99}
+ @param :id [String] El ID del modal
+ @param :modal_content_attributes [Hash] Atributos para el contenido del modal
+ @param :hide_actions [Array] Acciones al ocultar el modal
+ @yield :ID_body Contenido
+ @yield :ID_header Contenido del header (opcional)
+ @yield :ID_footer Contenido del pie (opcional)
+ @example
+ = render 'bootstrap/modal', id: 'algo' do |partial|
+ - content_for :algo_header do
+ = 'título'
+ - content_for :algo_body do
+ = 'contenido'
+ - content_for :algo_footer do
+ = 'pie'
+
+:ruby
+ local_assigns[:hide_actions] ||= []
+ local_assigns[:hide_actions] << 'click->modal#hide'
+ local_assigns[:keydown_actions] ||= []
+ local_assigns[:keydown_actions] << 'keydown->modal#hideWithEscape'
+ local_assigns[:modal_content_attributes] ||= {}
+
+-# XXX: Necesario para poder generar todas las demás
+= yield
+
+.modal.fade{ tabindex: -1, aria: { hidden: 'true' }, data: { 'modal-target': 'modal', action: local_assigns[:keydown_actions].join(' ') } }
+ .modal-backdrop.fade{ data: { 'modal-target': 'backdrop', action: local_assigns[:hide_actions].join(' ') } }
+ .modal-dialog.modal-dialog-scrollable.modal-dialog-centered.modal-lg
+ .modal-content{ **local_assigns[:modal_content_attributes] }
+ - if (header = yield(:"#{id}_header")).present?
+ .modal-header= header
+
+ .modal-body= yield(:"#{id}_body")
+
+ .modal-footer.flex-nowrap
+ - if (footer = yield(:"#{id}_footer"))
+ = footer
+ - else
+ %button.btn.btn-secondary.m-0{ type: 'button', data: { action: 'modal#hide' } }= t('.close')
diff --git a/app/views/bootstrap/_responsive.haml b/app/views/bootstrap/_responsive.haml
new file mode 100644
index 00000000..1b51de97
--- /dev/null
+++ b/app/views/bootstrap/_responsive.haml
@@ -0,0 +1,4 @@
+- local_assigns[:ratio] ||= '1by1'
+
+.embed-responsive{ class: "embed-responsive-#{local_assigns[:ratio]}" }
+ .embed-responsive-item= yield
diff --git a/app/views/build_stats/index.haml b/app/views/build_stats/index.haml
index de04d84d..c6ba4dfc 100644
--- a/app/views/build_stats/index.haml
+++ b/app/views/build_stats/index.haml
@@ -1,5 +1,5 @@
%main.row
- %aside.menu.col-md-3
+ %aside.menu.col-12.col-lg-3
= render 'sites/header', site: @site
.col
%h1= t('.title')
diff --git a/app/views/collaborations/collaborate.haml b/app/views/collaborations/collaborate.haml
index cc951b0c..bbdc977e 100644
--- a/app/views/collaborations/collaborate.haml
+++ b/app/views/collaborations/collaborate.haml
@@ -1,10 +1,10 @@
.row.align-items-center.justify-content-center.full-height
- .col-md-10.align-self-center
+ .col-12.col-lg-10.align-self-center
- welcome = @site.config.dig('welcome', 'message') || t('.welcome',
site: @site.hostname)
= sanitize_markdown welcome
- .col-md-6.align-self-center
+ .col-12.col-lg-6.align-self-center
-# Copiado y pegado de app/views/devise/registrations/new.haml
- resource = resource_name = @invitade
= form_for(resource, as: resource_name,
diff --git a/app/views/components/_dropdown.haml b/app/views/components/_dropdown.haml
index 6f34950b..923c603a 100644
--- a/app/views/components/_dropdown.haml
+++ b/app/views/components/_dropdown.haml
@@ -18,7 +18,7 @@
toggle: 'true',
display: 'static',
action: 'dropdown#toggle',
- target: 'dropdown.button'
+ 'dropdown-target': 'button'
},
aria: {
expanded: 'false'
@@ -28,7 +28,7 @@
.dropdown-menu{
class: dropdown_classes,
data: {
- target: 'dropdown.dropdown'
+ 'dropdown-target': 'dropdown'
}
}
= yield
diff --git a/app/views/components/_dropdown_button.haml b/app/views/components/_dropdown_button.haml
index d6de6c8e..2ec51470 100644
--- a/app/views/components/_dropdown_button.haml
+++ b/app/views/components/_dropdown_button.haml
@@ -3,4 +3,4 @@
@param value [String]
@param text [String]
- local_assigns.delete(:text)
-%button.dropdown-item{type: 'submit', data: { target: 'dropdown.item' }, name: name, value: value, **local_assigns.compact }= text
+%button.dropdown-item{type: 'submit', data: { 'dropdown-target': 'item' }, name: name, value: value, **local_assigns.compact }= text
diff --git a/app/views/components/_dropdown_item.haml b/app/views/components/_dropdown_item.haml
index a4d363a8..a8d0f6f8 100644
--- a/app/views/components/_dropdown_item.haml
+++ b/app/views/components/_dropdown_item.haml
@@ -2,4 +2,4 @@
@param :text [String] Contenido del link
@param :path [String,Hash] Link
- local_assigns[:class] = "dropdown-item #{local_assigns[:class]}"
-= link_to text, path, class: local_assigns[:class], data: { target: 'dropdown.item' }
+= link_to text, path, class: local_assigns[:class], data: { 'dropdown-target': 'item' }
diff --git a/app/views/components/_select_all.haml b/app/views/components/_select_all.haml
index 9778cd13..ebbb5c66 100644
--- a/app/views/components/_select_all.haml
+++ b/app/views/components/_select_all.haml
@@ -1,4 +1,4 @@
-#
@param id [String]
-= render 'components/checkbox', id: id, data: { action: 'select-all#toggle', target: 'select-all.toggle', **local_assigns.compact } do
+= render 'components/checkbox', id: id, data: { action: 'select-all#toggle', 'select-all-target': 'toggle', **local_assigns.compact } do
%span.sr-only= t('.label')
diff --git a/app/views/devise/confirmations/new.haml b/app/views/devise/confirmations/new.haml
index c934edc5..fed7523a 100644
--- a/app/views/devise/confirmations/new.haml
+++ b/app/views/devise/confirmations/new.haml
@@ -4,7 +4,7 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
- .col-md-4.align-self-center
+ .col-12.col-lg-5.align-self-center
.sr-only
%h2= t('.resend_confirmation_instructions')
diff --git a/app/views/devise/invitations/edit.haml b/app/views/devise/invitations/edit.haml
index 3d2f8d76..8039d281 100644
--- a/app/views/devise/invitations/edit.haml
+++ b/app/views/devise/invitations/edit.haml
@@ -4,7 +4,7 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
- .col-md-5.align-self-center
+ .col-12.col-lg-5.align-self-center
%h2= t 'devise.invitations.edit.header'
= form_for(resource,
as: resource_name,
diff --git a/app/views/devise/invitations/new.haml b/app/views/devise/invitations/new.haml
index b8b097d0..5fb6e0d0 100644
--- a/app/views/devise/invitations/new.haml
+++ b/app/views/devise/invitations/new.haml
@@ -4,7 +4,7 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
- .col-md-5.align-self-center
+ .col-12.col-lg-5.align-self-center
%h2= t 'devise.invitations.new.header'
= form_for(resource,
as: resource_name,
diff --git a/app/views/devise/mailer/invitation_instructions.html.haml b/app/views/devise/mailer/invitation_instructions.html.haml
index e87d99d9..d2d44a67 100644
--- a/app/views/devise/mailer/invitation_instructions.html.haml
+++ b/app/views/devise/mailer/invitation_instructions.html.haml
@@ -21,4 +21,4 @@
- elsif !@resource.confirmed? && @resource.confirmation_token
= confirmation_url(@resource, confirmation_token: @resource.confirmation_token, change_locale_to: @resource.lang)
- else
- %p= link_to t('devise.mailer.invitation_instructions.sign_in'), root_url
+ %p= link_to t('devise.mailer.invitation_instructions.sign_in'), root_url(change_locale_to: @resource.lang)
diff --git a/app/views/devise/passwords/edit.haml b/app/views/devise/passwords/edit.haml
index cd8ab8ad..3fab3727 100644
--- a/app/views/devise/passwords/edit.haml
+++ b/app/views/devise/passwords/edit.haml
@@ -4,7 +4,7 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
- .col-md-5.align-self-center
+ .col-12.col-lg-5.align-self-center
.sr-only
%h2= t('.change_your_password')
%p= t('.help')
diff --git a/app/views/devise/passwords/new.haml b/app/views/devise/passwords/new.haml
index 4bf7c990..3ef1b624 100644
--- a/app/views/devise/passwords/new.haml
+++ b/app/views/devise/passwords/new.haml
@@ -4,7 +4,7 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
- .col-md-5.align-self-center
+ .col-12.col-lg-5.align-self-center
.sr-only
%h2= t('.forgot_your_password')
%p= t('.help')
diff --git a/app/views/devise/registrations/edit.haml b/app/views/devise/registrations/edit.haml
index 8bdc55d9..495a6dea 100644
--- a/app/views/devise/registrations/edit.haml
+++ b/app/views/devise/registrations/edit.haml
@@ -6,7 +6,7 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
- .col-md-6.align-self-center
+ .col-12.col-lg-6.align-self-center
%h2= t('.title')
= form_for(resource,
as: resource_name,
diff --git a/app/views/devise/registrations/new.haml b/app/views/devise/registrations/new.haml
index aabc0487..99a969a1 100644
--- a/app/views/devise/registrations/new.haml
+++ b/app/views/devise/registrations/new.haml
@@ -4,7 +4,7 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
- .col-md-6.align-self-center
+ .col-12.col-lg-6.align-self-center
%h2= t('.sign_up')
%p= t('.help')
@@ -12,6 +12,9 @@
as: resource_name,
url: registration_path(resource_name, params: { locale: params[:locale] })) do |f|
+ .d-none
+ = f.text_field :name, autocomplete: 'off'
+
.form-group
= f.label :email, class: 'sr-only'
= f.email_field :email, autofocus: true, autocomplete: 'email',
diff --git a/app/views/devise/sessions/new.haml b/app/views/devise/sessions/new.haml
index 03c3974b..cd205e70 100644
--- a/app/views/devise/sessions/new.haml
+++ b/app/views/devise/sessions/new.haml
@@ -2,7 +2,7 @@
- 'black-bg'
.row.align-items-center.justify-content-center.full-height
- .col-md-5.align-self-center
+ .col-12.col-lg-5.align-self-center
.sr-only
%h2= t('.sign_in')
%p= t('.help')
@@ -28,11 +28,8 @@
placeholder: t('login.password')
- if devise_mapping.rememberable?
.form-group
- = f.check_box :remember_me, aria: { describedby: 'remember-for' }
- = f.label :remember_me
- %small.form-text.text-muted#remember-for
- = t('login.remember_me',
- remember_for: distance_of_time_in_words(Usuarie.remember_for))
+ = render 'bootstrap/custom_checkbox_for_field', field: f, name: :remember_me do
+ = t('login.remember_me', remember_for: distance_of_time_in_words(Usuarie.remember_for))
.actions
= f.submit t('.sign_in'),
class: 'btn btn-secondary btn-lg btn-block'
diff --git a/app/views/devise/shared/_links.haml b/app/views/devise/shared/_links.haml
index 5d5c0b67..f623ce8a 100644
--- a/app/views/devise/shared/_links.haml
+++ b/app/views/devise/shared/_links.haml
@@ -1,6 +1,6 @@
%hr/
-- locale = params.permit(:locale)
+- locale = { locale: (pluck_param(:locale, optional: true) || I18n.locale) }
- if controller_name != 'sessions'
= link_to t('.sign_in'), new_session_path(resource_name, params: locale),
diff --git a/app/views/devise/unlocks/new.haml b/app/views/devise/unlocks/new.haml
index 34253f44..eb55d707 100644
--- a/app/views/devise/unlocks/new.haml
+++ b/app/views/devise/unlocks/new.haml
@@ -4,7 +4,7 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
- .col-md-5.align-self-center
+ .col-12.col-lg-5.align-self-center
.sr-only
%h2= t('.resend_unlock_instructions')
%p= t('.help')
diff --git a/app/views/layouts/_breadcrumb.haml b/app/views/layouts/_breadcrumb.haml
index a946243a..5fa1b651 100644
--- a/app/views/layouts/_breadcrumb.haml
+++ b/app/views/layouts/_breadcrumb.haml
@@ -1,19 +1,20 @@
-%nav.navbar
- %a.navbar-brand.d-none.d-sm-block{ href: '/' }
+%nav.navbar.flex-md-nowrap.px-0
+ %a.navbar-brand.order-0{ href: '/' }
= inline_svg_tag 'sutty.svg', class: 'black', aria: true,
title: t('svg.sutty.title'), desc: t('svg.sutty.desc')
- %nav{ aria: { label: t('.title') } }
- %ol.breadcrumb.m-0.flex-wrap
- - breadcrumb_trail do |crumb|
- %li.breadcrumb-item{ class: crumb.current? ? 'active' : '' }
- - if crumb.current?
- %span.line-clamp-1{ aria: { current: 'page' } }= crumb.name
- - else
- %span.line-clamp-1= link_to crumb.name, crumb.url
+ - if breadcrumbs?
+ %nav.flex-grow-1.order-2.order-md-1{ aria: { label: t('.title') } }
+ %ol.breadcrumb.m-0.flex-wrap
+ - breadcrumb_trail do |crumb|
+ %li.breadcrumb-item{ class: crumb.current? ? 'active' : '' }
+ - if crumb.current?
+ %span.line-clamp-1{ aria: { current: 'page' } }= crumb.name
+ - else
+ = link_to crumb.name, crumb.url, class: 'line-clamp-1'
- - if @current_usuarie || current_usuarie
- %ul.navbar-nav.flex-row
+ %ul.navbar-nav.order-1.order-md-2.flex-row
+ - if @current_usuarie || current_usuarie
- if @site&.tienda?
%li.nav-item
= link_to t('.tienda'), @site.tienda_url,
@@ -26,8 +27,9 @@
%li.nav-item
= link_to t('.logout'), main_app.destroy_usuarie_session_path,
method: :delete, role: 'button', class: 'btn btn-secondary'
- - else
- - params.permit!
- - I18n.available_locales.each do |locale|
- - next if locale == I18n.locale
- = link_to t("switch_locale.#{locale}"), params.to_h.merge(change_locale_to: locale)
+ - else
+ - params.permit!
+ - I18n.available_locales.each do |locale|
+ - next if locale == I18n.locale
+ %li.nav-item
+ = link_to t("switch_locale.#{locale}"), params.to_h.merge(change_locale_to: locale)
diff --git a/app/views/layouts/_details.haml b/app/views/layouts/_details.haml
index a21f46c1..9f8a4658 100644
--- a/app/views/layouts/_details.haml
+++ b/app/views/layouts/_details.haml
@@ -7,8 +7,9 @@
@param :summary_class [String] Clases para el summary
- local_assigns[:summary_class] ||= 'h3'
+- local_assigns[:details_class] ||= 'py-2'
-%details.details.py-2{ id: local_assigns[:id], data: { controller: 'details', action: 'toggle->details#store' } }
+%details.details{ id: local_assigns[:id], class: local_assigns[:details_class], data: { controller: 'details', action: 'toggle->details#store' } }
%summary.d-flex.flex-row.align-items-center.justify-content-between{ class: local_assigns[:summary_class] }
%span= summary
%span.hide-when-open ▶
diff --git a/app/views/moderation_queue/_account.haml b/app/views/moderation_queue/_account.haml
index 498d78f4..211a62f1 100644
--- a/app/views/moderation_queue/_account.haml
+++ b/app/views/moderation_queue/_account.haml
@@ -3,7 +3,7 @@
.row.no-gutters.pt-2
.col-1
- = render 'components/checkbox', id: actor_moderation.id, form: form, name: 'actor_moderation[]', value: actor_moderation.id, data: { target: 'select-all.input' }
+ = render 'components/checkbox', id: actor_moderation.id, form: form, name: 'actor_moderation[]', value: actor_moderation.id, data: { 'select-all-target': 'input' }
.col-11
- cache [actor_moderation, profile] do
%h4
diff --git a/app/views/moderation_queue/_comment.haml b/app/views/moderation_queue/_comment.haml
index a80bd27c..88ea106b 100644
--- a/app/views/moderation_queue/_comment.haml
+++ b/app/views/moderation_queue/_comment.haml
@@ -22,7 +22,7 @@
.row.no-gutters
.col-1
- = render 'components/checkbox', id: activity_pub.id, name: 'activity_pub[]', value: activity_pub.id, data: { target: 'select-all.input' }, form: form
+ = render 'components/checkbox', id: activity_pub.id, name: 'activity_pub[]', value: activity_pub.id, data: { 'select-all-target': 'input' }, form: form
.col-11
- cache [activity_pub, comment] do
.d-flex.flex-row.align-items-center.justify-content-between
diff --git a/app/views/moderation_queue/_instance.haml b/app/views/moderation_queue/_instance.haml
index c380089a..793de6ac 100644
--- a/app/views/moderation_queue/_instance.haml
+++ b/app/views/moderation_queue/_instance.haml
@@ -4,7 +4,7 @@
.row.no-gutters.pt-2
.col-1
- = render 'components/checkbox', id: instance.hostname, form: form, name: 'instance_moderation[]', value: instance_moderation.id, data: { target: 'select-all.input' }
+ = render 'components/checkbox', id: instance.hostname, form: form, name: 'instance_moderation[]', value: instance_moderation.id, data: { 'select-all-target': 'input' }
.col-11
- cache [instance_moderation, instance] do
%h4
diff --git a/app/views/posts/_attributes.haml b/app/views/posts/_attributes.haml
new file mode 100644
index 00000000..03887866
--- /dev/null
+++ b/app/views/posts/_attributes.haml
@@ -0,0 +1,17 @@
+-#
+ @param base [String]
+ @param locale [String]
+ @param post [Post]
+ @param site [Site]
+ @param dir [String]
+ @param except [Array]
+- (post.attributes - local_assigns[:except].to_a).each do |attribute|
+ - metadata = post[attribute]
+ - type = metadata.type
+
+ - cache [metadata, I18n.locale] do
+ = render("posts/attributes/#{type}",
+ base: base, post: post, attribute: attribute,
+ metadata: metadata, site: site,
+ dir: dir, locale: locale,
+ autofocus: (post.attributes.first == attribute))
diff --git a/app/views/posts/_attributes_nested.haml b/app/views/posts/_attributes_nested.haml
new file mode 100644
index 00000000..5036b9c7
--- /dev/null
+++ b/app/views/posts/_attributes_nested.haml
@@ -0,0 +1,19 @@
+-#
+ @param inverse [Symbol]
+ @param base [String]
+ @param locale [String]
+ @param post [Post]
+ @param site [Site]
+ @param dir [String]
+- post.attributes.each do |attribute|
+ - next if attribute == :date
+ - next if attribute == :draft
+ - next if attribute == inverse
+
+ - metadata = post[attribute]
+
+ - cache [post, metadata, I18n.locale] do
+ = render "posts/attributes/#{metadata.type}",
+ base: base, post: post, attribute: attribute,
+ metadata: metadata, site: site,
+ dir: dir, locale: locale, autofocus: false
diff --git a/app/views/posts/_errors.haml b/app/views/posts/_errors.haml
new file mode 100644
index 00000000..3b0a89dd
--- /dev/null
+++ b/app/views/posts/_errors.haml
@@ -0,0 +1,19 @@
+- unless post.errors.empty?
+ - title = t('.errors.title')
+ - help = t('.errors.help')
+ = render 'bootstrap/alert' do
+ %h4= title
+ %p= help
+
+ %ul
+ - post.errors.each do |attribute, errors|
+ - if errors.size > 1
+ %li
+ %strong= post_label_t attribute, post: post
+ %ul
+ - errors.each do |error|
+ %li= error
+ - else
+ %li
+ %strong= post_label_t attribute, post: post
+ = errors.first
diff --git a/app/views/posts/_form.haml b/app/views/posts/_form.haml
index 7de0ea79..1c9c623e 100644
--- a/app/views/posts/_form.haml
+++ b/app/views/posts/_form.haml
@@ -1,22 +1,4 @@
-- unless post.errors.empty?
- - title = t('.errors.title')
- - help = t('.errors.help')
- = render 'bootstrap/alert' do
- %h4= title
- %p= help
-
- %ul
- - post.errors.each do |attribute, errors|
- - if errors.size > 1
- %li
- %strong= post_label_t attribute, post: post
- %ul
- - errors.each do |error|
- %li= error
- - else
- %li
- %strong= post_label_t attribute, post: post
- = errors.first
+= render 'errors', post: post
-# TODO: habilitar form_for
:ruby
@@ -31,26 +13,35 @@
end
- dir = t("locales.#{@locale}.dir")
+- submitting_id = random_id
+- invalid_id = random_id
+- data = {}
+- data[:controller] = 'unsaved-changes form-validation'
+- data[:action] = 'unsaved-changes#submit form-validation#submit beforeunload@window->unsaved-changes#unsaved turbolinks:before-visit@window->unsaved-changes#unsavedTurbolinks'
+- data[:'unsaved-changes-confirm-value'] = t('.confirm')
+- data[:'form-validation-submitting-id-value'] = submitting_id
+- data[:'form-validation-invalid-id-value'] = invalid_id
-# Comienza el formulario
-= form_tag url, method: method, class: 'form post ' + extra_class, multipart: true do
-
+= form_tag url, method: method, class: "form post #{extra_class}", multipart: true, data: data do
-# Botones de guardado
- = render 'posts/submit', site: site, post: post
+ = render 'posts/submit', site: site, post: post, invalid: invalid_id, submitting: submitting_id
= hidden_field_tag 'post[layout]', post.layout.name
-# Dibuja cada atributo
- - post.attributes.each do |attribute|
- - metadata = post[attribute]
- - type = metadata.type
-
- - cache [metadata, I18n.locale] do
- = render("posts/attributes/#{type}",
- base: 'post', post: post, attribute: attribute,
- metadata: metadata, site: site,
- dir: dir, locale: @locale,
- autofocus: (post.attributes.first == attribute))
+ = render 'posts/attributes', site: site, post: post, dir: dir, base: 'post', locale: @locale
-# Botones de guardado
- = render 'posts/submit', site: site, post: post
+ = render 'posts/submit', site: site, post: post, invalid: invalid_id, submitting: submitting_id
+
+-# Formularios usados por los modales
+= yield(:post_form)
+
+-#
+ Acumulador de formularios dinámicos, se van cargando a medida que se
+ necesitan en lugar de recursivamente.
+
+ Nunca se eliminan los modales una vez que se cargan para poder tener
+ historial de cambios.
+%div{ data: { controller: 'htmx', action: 'htmx:getUrl@window->htmx#beforeend' } }
diff --git a/app/views/posts/_htmx_form.haml b/app/views/posts/_htmx_form.haml
new file mode 100644
index 00000000..afa1b44c
--- /dev/null
+++ b/app/views/posts/_htmx_form.haml
@@ -0,0 +1,81 @@
+-#
+ El formulario del artículo, con HTMX activado.
+
+ @param :site [Site]
+ @param :post [Post]
+ @param :locale [Symbol, String]
+ @param :dir [Symbol, String]
+
+ @param [ActionController::StrongParameters] params
+ @option params [String] :inverse La relación inversa (opcional)
+ @option params [String] :form El ID del formulario actual, si tiene botones externos, tiene que estar compartido
+ @option params [String] :swap Método de intercambio del resultado (HTMX)
+ @option params [String] :target Elemento donde se carga el resultado (HTMX)
+ @option params [String] :hide ID del modal a esconder vía evento
+ @option params [String] :show ID del modal a mostrar vía evento
+ @option params [String] :base La base del formulario, que luego se envía como parámetro a PostService
+ @option params [String] :attribute El tipo de atributo, para saber qué respuesta generar
+:ruby
+ except = %i[date]
+
+ if (inverse = pluck_param(:inverse, optional: true))
+ except << inverse.to_sym
+ end
+
+ options = {
+ id: pluck_param(:form),
+ multipart: true,
+ class: 'form post ',
+ 'hx-swap': pluck_param(:swap),
+ 'hx-target': "##{pluck_param(:target)}",
+ 'hx-validate': true,
+ data: {
+ controller: 'unsaved-changes form-validation',
+ action: 'unsaved-changes#submit form-validation#submit beforeunload@window->unsaved-changes#unsaved turbolinks:before-visit@window->unsaved-changes#unsavedTurbolinks',
+ 'form-validation-submitting-id-value': pluck_param(:submitting, optional: true),
+ 'form-validation-invalid-id-value': pluck_param(:invalid, optional: true),
+ }
+ }
+
+ if post.new?
+ url = options[:'hx-post'] = site_posts_path(site, locale: locale)
+ options[:class] += 'new'
+ else
+ url = options[:'hx-patch'] = site_post_path(site, post.id, locale: locale)
+ options[:method] = :patch
+ options[:class] += 'edit'
+ end
+
+= form_tag url, **options do
+ = render 'errors', post: post
+
+ -# Parámetros para HTMX
+ %input{ type: 'hidden', name: 'modal_id', value: pluck_param(:modal_id, optional: true) }
+ %input{ type: 'hidden', name: 'hide', value: pluck_param((post.errors.empty? ? :show : :hide), optional: true) || pluck_param(:modal_id, optional: true) }
+ %input{ type: 'hidden', name: 'show', value: pluck_param((post.errors.empty? ? :hide : :show), optional: true) }
+ %input{ type: 'hidden', name: 'name', value: pluck_param(:name) }
+ %input{ type: 'hidden', name: 'base', value: pluck_param(:base) }
+ %input{ type: 'hidden', name: 'form', value: options[:id] }
+ %input{ type: 'hidden', name: 'dir', value: dir }
+ %input{ type: 'hidden', name: 'locale', value: locale }
+ %input{ type: 'hidden', name: 'attribute', value: pluck_param(:attribute) }
+ %input{ type: 'hidden', name: 'target', value: pluck_param(:target) }
+ %input{ type: 'hidden', name: 'swap', value: pluck_param(:swap) }
+ - if params[:inverse].present?
+ %input{ type: 'hidden', name: 'inverse', value: pluck_param(:inverse) }
+ - if params[:saved].present?
+ %input{ type: 'hidden', name: 'saved', value: pluck_param(:saved) }
+
+ = hidden_field_tag "#{base}[layout]", post.layout.name
+
+ -# Dibuja cada atributo, excepto algunos
+ = render 'posts/attributes', site: site, post: post, dir: dir, base: base, locale: locale, except: except
+
+ -#
+ Enviamos valores vacíos o arrastrados desde el formulario anterior
+ para los atributos ignorados
+ - except.each do |attr|
+ - if (value = pluck_param(attr, optional: true)).present?
+ %input{ type: 'hidden', name: "#{base}[#{attr}]", value: value }
+
+= yield(:post_form)
diff --git a/app/views/posts/_new_array_value.haml b/app/views/posts/_new_array_value.haml
new file mode 100644
index 00000000..73bea5dc
--- /dev/null
+++ b/app/views/posts/_new_array_value.haml
@@ -0,0 +1 @@
+%li= value
diff --git a/app/views/posts/_new_has_one.haml b/app/views/posts/_new_has_one.haml
new file mode 100644
index 00000000..f5286670
--- /dev/null
+++ b/app/views/posts/_new_has_one.haml
@@ -0,0 +1,2 @@
+= render 'posts/new_related_post', post: post, modal_id: modal_id
+%input{ type: 'hidden', name: name, value: value }
diff --git a/app/views/posts/_new_related_post.haml b/app/views/posts/_new_related_post.haml
new file mode 100644
index 00000000..236abc42
--- /dev/null
+++ b/app/views/posts/_new_related_post.haml
@@ -0,0 +1,32 @@
+:ruby
+ image = nil
+ description = nil
+ card_id = random_id
+
+ if post.post.attribute?(:image) && (image = post.post.image.static_file)
+ description = post.post.image.value['description']
+ end
+
+.col.mb-3.p-1{ id: card_id, data: { controller: 'modal' } }
+ = render('bootstrap/card', image: image, description: description, title: post.title, class: 'h-100') do
+ - if post.post.attribute?(:description)
+ %p.card-text= post.post.description.value
+
+ -#
+ Si pasamos el ID del modal, asumimos que hay uno que ya existe y
+ lo llamamos. Sino, tenemos que abrir el modal genérico y cargarle
+ el formulario vía "HTMX".
+
+ - if local_assigns[:modal_id].present?
+ = render 'bootstrap/btn', content: t('.edit'), data: { action: 'modal#showAnother', 'modal-show-value': local_assigns[:modal_id] }, id: random_id
+ - else
+ - form_params = {}
+ - form_params[:layout] = post.layout
+ - form_params[:uuid] = post.post_id
+ - form_params[:modal_id] = form_params[:show] = modal_id = random_id
+ -# Asociar un modal con una tarjeta
+ - form_params[:result_id] = card_id
+ - form_params[:inverse] = local_assigns[:inverse]
+
+ -# @todo Poder indicar en qué elemento queremos asociar lo descargado
+ = render 'bootstrap/btn', content: t('.edit'), data: { controller: 'htmx', action: 'modal#showAnother htmx#getUrlOnce', 'modal-show-value': modal_id, 'htmx-get-url-param': site_posts_modal_path(post.site, **form_params) }, id: random_id
diff --git a/app/views/posts/_required_checkbox.haml b/app/views/posts/_required_checkbox.haml
new file mode 100644
index 00000000..cf2fc0de
--- /dev/null
+++ b/app/views/posts/_required_checkbox.haml
@@ -0,0 +1,19 @@
+-#
+ Para el controlador required-checkbox necesitamos un checkbox oculto
+ que es obligatorio según si alguno de los checkboxes reales está
+ seleccionado o no. Al ser obligatorio, va a tener feedback de
+ validación. Sin embargo, como está oculto, no podemos mostrar el
+ mensaje de validación nativo del navegador.
+
+ @param :required [Boolean]
+ @param :name [String,Symbol]
+ @param :initial [Boolean]
+ @param :feedback [String]
+ @param :type [String]
+
+- if required
+ - local_assigns[:feedback] ||= t('.required')
+ - local_assigns[:type] ||= 'checkbox'
+
+ %input.form-control.d-none{ type: local_assigns[:type], name: name, data: { 'required-checkbox-target': 'required', action: 'invalid->required-checkbox#invalid' }, required: initial }
+ .invalid-feedback.mt-0= local_assigns[:feedback]
diff --git a/app/views/posts/_submit.haml b/app/views/posts/_submit.haml
index c6c0a68a..41d6f420 100644
--- a/app/views/posts/_submit.haml
+++ b/app/views/posts/_submit.haml
@@ -1,8 +1,3 @@
-- invalid_help = site.config.fetch('invalid_help', t('.invalid_help'))
-- sending_help = site.config.fetch('sending_help', t('.sending_help'))
-.form-group
- = submit_tag t('.save'), class: 'btn btn-secondary submit-post'
- = render 'bootstrap/alert', class: 'invalid-help d-none' do
- = invalid_help
- = render 'bootstrap/alert', class: 'sending-help d-none' do
- = sending_help
+.d-flex.flex-column.flex-md-row.align-items-start.mb-3
+ %div= submit_tag t('.save'), class: 'btn btn-secondary submit-post'
+ = render 'posts/validation', site: site, submitting: { id: submitting }, invalid: { id: invalid }
diff --git a/app/views/posts/_table.haml b/app/views/posts/_table.haml
new file mode 100644
index 00000000..ff35aace
--- /dev/null
+++ b/app/views/posts/_table.haml
@@ -0,0 +1,34 @@
+-#
+ Muestra una tabla con todos los atributos de un post
+
+ @param site [Site]
+ @param locale [Symbol]
+ @param dir [String]
+ @param post [Post]
+ @param title [String]
+
+%table.table.table-condensed
+ %thead
+ %tr
+ %th.text-center{ colspan: 2 }= title
+ %tbody
+ - post.attributes.each do |attr|
+ - metadata = post[attr]
+ - next unless metadata.front_matter?
+
+ - cache [post, metadata, I18n.locale] do
+ = render("posts/attribute_ro/#{metadata.type}",
+ post: post, attribute: attr,
+ metadata: metadata,
+ site: site,
+ locale: locale,
+ dir: dir)
+
+-# Mostrar todo lo que no va en el front_matter (el contenido)
+- post.attributes.each do |attr|
+ - metadata = post[attr]
+ - next if metadata.front_matter?
+
+ - cache [post, metadata, I18n.locale] do
+ %section.content.pb-3{ id: attr, dir: dir }
+ = metadata.to_s.html_safe
diff --git a/app/views/posts/_validation.haml b/app/views/posts/_validation.haml
new file mode 100644
index 00000000..c28a743a
--- /dev/null
+++ b/app/views/posts/_validation.haml
@@ -0,0 +1,16 @@
+- invalid = site.config.fetch('invalid', t('.invalid'))
+- submitting = site.config.fetch('submitting', t('.submitting'))
+- %i[invalid submitting].each do |key|
+ - local_assigns[key] ||= {}
+ - local_assigns[key][:data] ||= {}
+ - local_assigns[key][:data][:target] ||= "form-validation.#{key}"
+ - local_assigns[key][:data][:action] ||= 'notification:show@window->notification#show'
+ - local_assigns[key][:data][:controller] ||= 'notification'
+ - local_assigns[key][:data][:'notification-hide-class'] ||= 'hide'
+ - local_assigns[key][:data][:'notification-show-class'] ||= 'show'
+
+.d-flex.flex-column
+ = render 'bootstrap/alert', class: 'm-0 d-none fade', **local_assigns[:invalid] do
+ = invalid
+ = render 'bootstrap/alert', class: 'm-0 d-none fade', **local_assigns[:submitting] do
+ = submitting
diff --git a/app/views/posts/attribute_ro/_date.haml b/app/views/posts/attribute_ro/_date.haml
index cf8ea8ac..25ba9430 100644
--- a/app/views/posts/attribute_ro/_date.haml
+++ b/app/views/posts/attribute_ro/_date.haml
@@ -1,3 +1,5 @@
%tr{ id: attribute }
%th= post_label_t(attribute, post: post)
- %td{ dir: dir, lang: locale }= l metadata.value.to_date
+ %td{ dir: dir, lang: locale }
+ - if metadata.value
+ = l metadata.value
diff --git a/app/views/posts/attribute_ro/_has_one.haml b/app/views/posts/attribute_ro/_has_one.haml
new file mode 100644
index 00000000..425e659e
--- /dev/null
+++ b/app/views/posts/attribute_ro/_has_one.haml
@@ -0,0 +1,6 @@
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td{ dir: dir, lang: locale }
+ - p = metadata.has_one
+ - if p
+ = link_to p.title.value, site_post_path(site, p.id)
diff --git a/app/views/posts/attribute_ro/_has_one_nested.haml b/app/views/posts/attribute_ro/_has_one_nested.haml
new file mode 100644
index 00000000..1c89474e
--- /dev/null
+++ b/app/views/posts/attribute_ro/_has_one_nested.haml
@@ -0,0 +1,6 @@
+%tr{ id: attribute }
+ %td{ dir: dir, lang: locale, colspan: 2 }
+ - if (p = metadata.has_one)
+ = render 'layouts/details', details_class: '', summary_class: 'font-weight-bold', summary: post_label_t(attribute, post: post) do
+ .mt-3
+ = render 'posts/table', site: site, post: p, dir: dir, locale: locale, title: p.layout.humanized_name
diff --git a/app/views/posts/attribute_ro/_new_array.haml b/app/views/posts/attribute_ro/_new_array.haml
new file mode 100644
index 00000000..20a0a545
--- /dev/null
+++ b/app/views/posts/attribute_ro/_new_array.haml
@@ -0,0 +1,8 @@
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td
+ - if metadata.value.respond_to? :each
+ - metadata.value.each do |v|
+ %span.badge.badge-primary= v
+ - else
+ %span.badge.badge-primary{ lang: locale, dir: dir }= metadata.value
diff --git a/app/views/posts/attribute_ro/_new_belongs_to.haml b/app/views/posts/attribute_ro/_new_belongs_to.haml
new file mode 100644
index 00000000..c7e06be8
--- /dev/null
+++ b/app/views/posts/attribute_ro/_new_belongs_to.haml
@@ -0,0 +1,6 @@
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td{ dir: dir, lang: locale }
+ - p = metadata.belongs_to
+ - if p
+ = link_to p.title.value, site_post_path(site, p.id)
diff --git a/app/views/posts/attribute_ro/_new_has_and_belongs_to_many.haml b/app/views/posts/attribute_ro/_new_has_and_belongs_to_many.haml
new file mode 100644
index 00000000..d6b51a7a
--- /dev/null
+++ b/app/views/posts/attribute_ro/_new_has_and_belongs_to_many.haml
@@ -0,0 +1,6 @@
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td
+ %ul{ dir: dir, lang: locale }
+ - metadata.has_many.each do |p|
+ %li= link_to p.title.value, site_post_path(site, p.id)
diff --git a/app/views/posts/attribute_ro/_new_has_many.haml b/app/views/posts/attribute_ro/_new_has_many.haml
new file mode 100644
index 00000000..d6b51a7a
--- /dev/null
+++ b/app/views/posts/attribute_ro/_new_has_many.haml
@@ -0,0 +1,6 @@
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td
+ %ul{ dir: dir, lang: locale }
+ - metadata.has_many.each do |p|
+ %li= link_to p.title.value, site_post_path(site, p.id)
diff --git a/app/views/posts/attribute_ro/_new_has_one.haml b/app/views/posts/attribute_ro/_new_has_one.haml
new file mode 100644
index 00000000..425e659e
--- /dev/null
+++ b/app/views/posts/attribute_ro/_new_has_one.haml
@@ -0,0 +1,6 @@
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td{ dir: dir, lang: locale }
+ - p = metadata.has_one
+ - if p
+ = link_to p.title.value, site_post_path(site, p.id)
diff --git a/app/views/posts/attribute_ro/_new_predefined_array.haml b/app/views/posts/attribute_ro/_new_predefined_array.haml
new file mode 100644
index 00000000..88a82626
--- /dev/null
+++ b/app/views/posts/attribute_ro/_new_predefined_array.haml
@@ -0,0 +1,5 @@
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td
+ - metadata.value.each do |v|
+ %span.badge.badge-primary{ dir: dir, lang: locale }= metadata.values.key v
diff --git a/app/views/posts/attribute_ro/_new_predefined_value.haml b/app/views/posts/attribute_ro/_new_predefined_value.haml
new file mode 100644
index 00000000..d44eef69
--- /dev/null
+++ b/app/views/posts/attribute_ro/_new_predefined_value.haml
@@ -0,0 +1,3 @@
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td{ dir: dir, lang: locale }= metadata.to_s
diff --git a/app/views/posts/attribute_ro/_permalink.haml b/app/views/posts/attribute_ro/_permalink.haml
index 67642e2c..13f878c6 100644
--- a/app/views/posts/attribute_ro/_permalink.haml
+++ b/app/views/posts/attribute_ro/_permalink.haml
@@ -1,3 +1,3 @@
%tr{ id: attribute }
%th= post_label_t(attribute, post: post)
- %td{ dir: dir, lang: locale }= metadata.value
+ %td{ dir: dir, lang: locale }= link_to post.absolute_url, post.absolute_url, target: '_blank', rel: 'noopener'
diff --git a/app/views/posts/attribute_ro/_plain_text.haml b/app/views/posts/attribute_ro/_plain_text.haml
new file mode 100644
index 00000000..e6d9a274
--- /dev/null
+++ b/app/views/posts/attribute_ro/_plain_text.haml
@@ -0,0 +1,3 @@
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td{ lang: locale, dir: dir }= metadata.value
diff --git a/app/views/posts/attribute_ro/_predefined_value.haml b/app/views/posts/attribute_ro/_predefined_value.haml
index 67642e2c..d44eef69 100644
--- a/app/views/posts/attribute_ro/_predefined_value.haml
+++ b/app/views/posts/attribute_ro/_predefined_value.haml
@@ -1,3 +1,3 @@
%tr{ id: attribute }
%th= post_label_t(attribute, post: post)
- %td{ dir: dir, lang: locale }= metadata.value
+ %td{ dir: dir, lang: locale }= metadata.to_s
diff --git a/app/views/posts/attribute_ro/_title.haml b/app/views/posts/attribute_ro/_title.haml
new file mode 100644
index 00000000..67642e2c
--- /dev/null
+++ b/app/views/posts/attribute_ro/_title.haml
@@ -0,0 +1,3 @@
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td{ dir: dir, lang: locale }= metadata.value
diff --git a/app/views/posts/attributes/_boolean.haml b/app/views/posts/attributes/_boolean.haml
index e07feca4..050ede70 100644
--- a/app/views/posts/attributes/_boolean.haml
+++ b/app/views/posts/attributes/_boolean.haml
@@ -1,12 +1,16 @@
-.form-check
- = hidden_field_tag "#{base}[#{attribute}]", '0', id: ''
- .custom-control.custom-switch
- = check_box_tag "#{base}[#{attribute}]", '1', metadata.value,
- class: "custom-control-input #{invalid(post, attribute)}",
- aria: { describedby: id_for_help(attribute) },
- autofocus: autofocus
- = label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post),
- class: 'custom-control-label'
+-# TODO: convertir los atributos draft a un tipo
+- if attribute == :draft && site.invitade?(current_usuarie)
+ -# Nada
+- else
+ .form-check
+ = hidden_field_tag "#{base}[#{attribute}]", '0', id: nil
+ .custom-control.custom-switch
+ = check_box_tag "#{base}[#{attribute}]", '1', metadata.value,
+ class: "custom-control-input #{invalid(post, attribute)}",
+ aria: { describedby: id_for_help(attribute) },
+ autofocus: autofocus
+ = label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post),
+ class: 'custom-control-label'
- = render 'posts/attribute_feedback',
- post: post, attribute: attribute, metadata: metadata
+ = render 'posts/attribute_feedback',
+ post: post, attribute: attribute, metadata: metadata
diff --git a/app/views/posts/attributes/_date.haml b/app/views/posts/attributes/_date.haml
index 1347c59c..f875c53e 100644
--- a/app/views/posts/attributes/_date.haml
+++ b/app/views/posts/attributes/_date.haml
@@ -1,6 +1,6 @@
.form-group
= label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
- = date_field base, attribute, value: metadata.value.to_date.strftime('%F'),
+ = date_field base, attribute, value: metadata.value&.strftime('%F'),
**field_options(attribute, metadata), pattern: '\d{4}-\d{2}-\d{2}',
data: { 'pattern-mismatch': t('metadata.date.invalid_format') }
= render 'posts/attribute_feedback',
diff --git a/app/views/posts/attributes/_file.haml b/app/views/posts/attributes/_file.haml
index 20c27399..4b27481d 100644
--- a/app/views/posts/attributes/_file.haml
+++ b/app/views/posts/attributes/_file.haml
@@ -1,14 +1,15 @@
.form-group{ data: { controller: 'file-preview' } }
+ = label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
- if metadata.static_file
- case metadata.static_file.blob.content_type
- when %r{\Avideo/}
= video_tag url_for(metadata.static_file),
controls: true, class: 'img-fluid',
- data: { target: 'file-preview.preview' }
+ data: { 'file-preview-target': 'preview' }
- when %r{\Aaudio/}
= audio_tag url_for(metadata.static_file),
controls: true, class: 'img-fluid',
- data: { target: 'file-preview.preview' }
+ data: { 'file-preview-target': 'preview' }
- when 'application/pdf'
%iframe{ src: url_for(metadata.static_file) }
- else
@@ -26,7 +27,8 @@
= file_field(*field_name_for(base, attribute, :path),
**field_options(attribute, metadata, required: (metadata.required && !metadata.path?)),
class: "custom-file-input #{invalid(post, attribute)}",
- data: { target: 'file-preview.input', action: 'file-preview#update' })
+ lang: locale,
+ data: { 'file-preview-target': 'input', action: 'file-preview#update' })
= label_tag "#{base}_#{attribute}_path",
post_label_t(attribute, :path, post: post), class: 'custom-file-label'
= render 'posts/attribute_feedback',
diff --git a/app/views/posts/attributes/_geo.haml b/app/views/posts/attributes/_geo.haml
index dee4707e..cd21a6f3 100644
--- a/app/views/posts/attributes/_geo.haml
+++ b/app/views/posts/attributes/_geo.haml
@@ -10,7 +10,7 @@
= text_field(*field_name_for(base, attribute, :lat),
value: metadata.value['lat'],
**field_options(attribute, metadata),
- data: { target: 'geo.lat' })
+ data: { 'geo-target': 'lat' })
= render 'posts/attribute_feedback',
post: post, attribute: [attribute, :lat], metadata: metadata
.col
@@ -20,8 +20,8 @@
= text_field(*field_name_for(base, attribute, :lng),
value: metadata.value['lng'],
**field_options(attribute, metadata),
- data: { target: 'geo.lng' })
+ data: { 'geo-target': 'lng' })
= render 'posts/attribute_feedback',
post: post, attribute: [attribute, :lng], metadata: metadata
.col-12.mb-3
- %div{ data: { target: 'geo.map' }, style: 'height: 250px' }
+ %div{ data: { 'geo-target': 'map' }, style: 'height: 250px' }
diff --git a/app/views/posts/attributes/_has_one.haml b/app/views/posts/attributes/_has_one.haml
new file mode 100644
index 00000000..b0d21f35
--- /dev/null
+++ b/app/views/posts/attributes/_has_one.haml
@@ -0,0 +1,7 @@
+.form-group
+ = label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
+ = select_tag(plain_field_name_for(base, attribute),
+ options_for_select(metadata.values, metadata.value),
+ **field_options(attribute, metadata), include_blank: t('.empty'))
+ = render 'posts/attribute_feedback',
+ post: post, attribute: attribute, metadata: metadata
diff --git a/app/views/posts/attributes/_has_one_nested.haml b/app/views/posts/attributes/_has_one_nested.haml
new file mode 100644
index 00000000..4aabf386
--- /dev/null
+++ b/app/views/posts/attributes/_has_one_nested.haml
@@ -0,0 +1,6 @@
+- nested_post = metadata.has_one || site.posts(lang: locale).build(layout: metadata.nested)
+- base = "#{base}[#{metadata.name}]"
+
+.form-group
+ = render 'layouts/details', id: metadata.nested, summary: site.layouts[metadata.nested].humanized_name do
+ = render 'posts/attributes_nested', site: site, post: nested_post, dir: dir, base: base, locale: locale, inverse: metadata.inverse
diff --git a/app/views/posts/attributes/_image.haml b/app/views/posts/attributes/_image.haml
index 241a78e8..69e99847 100644
--- a/app/views/posts/attributes/_image.haml
+++ b/app/views/posts/attributes/_image.haml
@@ -1,9 +1,10 @@
.form-group{ data: { controller: 'file-preview' } }
+ = label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
- if metadata.static_file
= image_tag url_for(metadata.static_file),
alt: metadata.value['description'],
class: 'img-fluid',
- data: { target: 'file-preview.preview' }
+ data: { 'file-preview-target': 'preview' }
-# Mantener el valor si no enviamos ninguna imagen
= hidden_field_tag "#{base}[#{attribute}][path]", metadata.value['path']
@@ -16,14 +17,15 @@
= image_tag '',
alt: metadata.value['description'],
class: 'img-fluid',
- data: { target: 'file-preview.preview' }
+ data: { 'file-preview-target': 'preview' }
.custom-file
= file_field(*field_name_for(base, attribute, :path),
**field_options(attribute, metadata, required: (metadata.required && !metadata.path?)),
- class: "custom-file-input #{invalid(post, attribute)}",
+ class: ['custom-file-input', invalid(post, attribute), ('replace-image' if metadata.static_file)].compact.join(' '),
accept: ActiveStorage.web_image_content_types.join(','),
- data: { target: 'file-preview.input', action: 'file-preview#update' })
+ lang: locale,
+ data: { 'file-preview-target': 'input', action: 'file-preview#update' })
= label_tag "#{base}_#{attribute}_path",
post_label_t(attribute, :path, post: post), class: 'custom-file-label'
= render 'posts/attribute_feedback',
diff --git a/app/views/posts/attributes/_new_array.haml b/app/views/posts/attributes/_new_array.haml
new file mode 100644
index 00000000..1ed82e98
--- /dev/null
+++ b/app/views/posts/attributes/_new_array.haml
@@ -0,0 +1,71 @@
+-#
+ Genera un listado de checkboxes entre los que se puede elegir para guardar
+:ruby
+ id = id_for(base, attribute)
+ name = "#{base}[#{attribute}][]"
+ form_id = random_id
+ controllers = %w[modal array enter]
+ controllers << 'required-checkbox' if metadata.required
+
+%div{ data: { controller: controllers.join(' '), 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_array_value_path(site) } }
+ %template{ data: { 'array-target': 'placeholder' } }
+ .col.mb-3{ 'aria-hidden': 'true' }
+ %span.placeholder.w-100
+
+ .form-group.mb-0
+ -#
+ Si la lista es obligatoria, al menos uno de los ítems tiene que
+ estar activado. Logramos esto con un checkbox oculto que se marca
+ como obligatorio al validar el formulario.
+ .d-flex.align-items-center.justify-content-between
+ %div
+ = label_tag id, post_label_t(attribute, post: post), class: 'mb-0 h3'
+ = render 'posts/required_checkbox', required: metadata.required, name: name, initial: metadata.empty?
+ = render 'bootstrap/btn', content: t('.edit'), action: 'modal#show'
+ -# Mostramos la lista de valores actuales.
+
+ Al aceptar el modal, se vacía el listado y se completa en base a
+ renderizaciones con HTMX. Para poder hacer eso, tenemos que poder
+ acceder a todos los items dentro del modal (como array.item) y
+ enviar el valor al endpoint que devuelve uno por uno. Esto lo
+ tenemos disponible en Stimulus, pero queremos usar HTMX o técnica
+ similar para poder renderizar del lado del servidor.
+
+ Para poder cancelar, mantenemos el estado original y desactivamos
+ o activamos los ítemes según estén incluidos en esa lista o no.
+ %ul.placeholder-glow{ data: { 'array-target': 'current' } }
+ - metadata.value.each do |value|
+ = render 'posts/new_array_value', value: value
+
+ = render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'], keydown_actions: %w[keydown->array#cancelWithEscape] do
+ - content_for :"#{id}_header" do
+ .form-group.flex-grow-1.mb-0
+ = label_tag id, post_label_t(attribute, post: post), class: 'mb-0'
+ %small.feedback.form-text.text-muted.mt-0.mb-1= post_help_t(metadata.name, post: post)
+ %input.form-control{ data: { 'array-target': 'search', action: 'input->array#search keydown->enter#prevent' }, type: 'search', placeholder: t('.filter') }
+
+ - content_for :"#{id}_body" do
+ .form-group.mb-0{ id: "#{id}_body" }
+ -# Eliminamos las tildes para poder buscar independientemente de cómo se escriba.
+ - metadata.values.each do |value|
+ = render 'targets/array/item', value: value, class: 'mb-2' do
+ = render 'bootstrap/custom_checkbox', name: name, id: random_id, value: value, checked: metadata.value.include?(value), content: value, data: { action: 'required-checkbox#change', 'required-checkbox-target': 'checkbox' }
+
+ - content_for :"#{id}_footer" do
+ .input-group.w-auto.flex-grow-1.my-0
+ %input.form-control{ form: form_id, name: 'value', type: 'text', placeholder: t('.add_new'), required: true }
+ .input-group-append
+ = render 'bootstrap/btn', content: t('.add', layout: ''), form: form_id, type: 'submit', class: 'mb-0 mr-0'
+ = render 'bootstrap/btn', content: t('.accept'), action: 'array#accept modal#hide', class: 'm-0 mr-1'
+ = render 'bootstrap/btn', content: t('.cancel'), action: 'array#cancel modal#hide', class: 'm-0'
+
+ -# Los formularios para HTMX se colocan por fuera del formulario
+ principal, porque HTML5 no soporta formularios anidados. Los campos
+ quedan unidos al formulario por su atributo `id`.
+
+ Al enviar el formulario se obtiene una nueva opción con el valor
+ y se la agrega al final del listado.
+ - content_for :post_form do
+ %form{ id: form_id, 'hx-get': site_posts_new_array_path(site), 'hx-target': "##{id}_body", 'hx-swap': 'beforeend' }
+ %input{ type: 'hidden', name: 'name', value: name }
+ %input{ type: 'hidden', name: 'id', value: form_id }
diff --git a/app/views/posts/attributes/_new_belongs_to.haml b/app/views/posts/attributes/_new_belongs_to.haml
new file mode 100644
index 00000000..661f3d27
--- /dev/null
+++ b/app/views/posts/attributes/_new_belongs_to.haml
@@ -0,0 +1,111 @@
+-#
+ Genera un listado de radios entre los que se puede elegir solo uno para
+ guardar. Podemos elegir entre los artículos ya cargados o agregar uno
+ nuevo.
+
+ Al agregar uno nuevo, se abre un segundo modal que carga el formulario
+ correspondiente vía HTMX. El formulario tiene que cargarse por fuera
+ del formulario principal porque no se pueden anidar.
+
+:ruby
+ id = random_id
+ name = "#{base}[#{attribute}]"
+ form_id = random_id
+ modal_id = random_id
+ post_id = random_id
+ post_form_id = random_id
+ post_modal_id = random_id
+ post_form_loaded_id = random_id
+ value_list_id = random_id
+ controllers = %w[modal array]
+ controllers << 'required-checkbox' if metadata.required
+
+%div{ id: modal_id, data: { controller: controllers.join(' '), 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_related_post_path(site) } }
+ %template{ data: { 'array-target': 'placeholder' } }
+ .col.p-3{ 'aria-hidden': 'true' }
+ %span.placeholder.w-100
+
+ .form-group
+ .d-flex.align-items-center.justify-content-between
+ %div
+ = label_tag id, post_label_t(attribute, post: post), class: 'mb-0'
+ = render 'posts/required_checkbox', required: metadata.required, name: name, initial: metadata.empty?, type: 'radio'
+ = render 'bootstrap/btn', content: t('.edit'), action: 'modal#show'
+
+ -# Mostramos la lista de valores actuales.
+
+ Al aceptar el modal, se vacía el listado y se completa en base a
+ renderizaciones con HTMX. Para poder hacer eso, tenemos que poder
+ acceder a todos los items dentro del modal (como array.item) y
+ enviar el valor al endpoint que devuelve uno por uno. Esto lo
+ tenemos disponible en Stimulus, pero queremos usar HTMX o técnica
+ similar para poder renderizar del lado del servidor.
+
+ Para poder cancelar, mantenemos el estado original y desactivamos
+ o activamos los ítemes según estén incluidos en esa lista o no.
+ .row.no-gutters.placeholder-glow{ data: { 'array-target': 'current' } }
+ -# @todo issue-7537
+ - if !metadata.empty? && (indexed_post = site.indexed_posts.find_by(post_id: metadata.value))
+ = render 'posts/new_related_post', post: indexed_post, attribute: metadata.type, inverse: metadata.inverse
+
+ = render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'], keydown_actions: %w[keydown->array#cancelWithEscape] do
+ - content_for :"#{id}_header" do
+ .form-group.flex-grow-1.mb-0
+ = label_tag id, post_label_t(attribute, post: post)
+ %input.form-control{ data: { 'array-target': 'search', action: 'input->array#search' }, type: 'search', placeholder: t('.filter') }
+
+ - content_for :"#{id}_body" do
+ .form-group.mb-0{ id: value_list_id }
+ - metadata.values.each do |(value, uuid, disabled)|
+ = render 'targets/array/item', value: uuid, 'send-value': uuid, 'human-value': value, class: 'mb-2' do
+ = render 'bootstrap/custom_checkbox', name: name, id: random_id, value: uuid, checked: metadata.value.include?(uuid), content: value, type: 'radio', disabled: disabled do
+ = t('posts.attributes.new_belongs_to.disabled') if disabled
+
+ -#
+ Según la definición del campo, si hay un filtro, tenemos que poder
+ elegir qué tipo de esquema queremos o si hay uno solo, siempre
+ vamos a enviar ese. Si no hay ninguno, tendríamos que poder elegir
+ entre todos los esquemas.
+
+ - content_for :"#{id}_footer" do
+ - layout = metadata.filter[:layout]
+ - if layout.is_a?(String)
+ %input{ type: 'hidden', name: 'layout', value: layout, form: post_form_id }
+ = render 'bootstrap/btn', content: t('.add', layout: site.layouts[layout].humanized_name), form: post_form_id, type: 'submit', class: 'm-0 mr-1'
+ - else
+ - layouts = layout&.map { |x| site.layouts[x] }
+ - layouts ||= site.layouts.values
+ .input-group.w-auto.flex-grow-1.my-0
+ %select.form-control{ form: post_form_id, name: 'layout' }
+ - layouts.each do |layout|
+ %option{ value: layout.name }= layout.humanized_name
+ .input-group-append
+ = render 'bootstrap/btn', content: t('.add', layout: ''), form: post_form_id, type: 'submit', class: 'mb-0 mr-0'
+ = render 'bootstrap/btn', content: t('.accept'), action: 'array#accept modal#hide', class: 'm-0 mr-1'
+ = render 'bootstrap/btn', content: t('.cancel'), action: 'array#cancel modal#hide', class: 'm-0'
+
+-#
+ Este segundo modal es el que carga los formularios de
+ creación/modificación de artículos relacionados. Se envía a post_form
+ para que sea externo al formulario actual.
+- content_for :post_form do
+ %form{ id: post_form_id, 'hx-get': site_posts_form_path(site), 'hx-target': "##{post_form_loaded_id}" }
+ %input{ type: 'hidden', name: 'show', value: post_modal_id }
+ %input{ type: 'hidden', name: 'hide', value: modal_id }
+ %input{ type: 'hidden', name: 'target', value: value_list_id }
+ %input{ type: 'hidden', name: 'swap', value: 'beforeend' }
+ %input{ type: 'hidden', name: 'base', value: id }
+ %input{ type: 'hidden', name: 'name', value: name }
+ %input{ type: 'hidden', name: 'form', value: form_id }
+ %input{ type: 'hidden', name: 'attribute', value: metadata.type }
+ - if metadata.inverse?
+ %input{ type: 'hidden', name: 'inverse', value: metadata.inverse }
+ %input{ type: 'hidden', name: metadata.inverse, value: post.uuid.value }
+ %div{ id: post_modal_id, data: { controller: 'modal' } }
+ = render 'bootstrap/modal', id: post_id, modal_content_attributes: { class: 'h-100' } do
+ - content_for :"#{post_id}_body" do
+ %div{ id: post_form_loaded_id }
+ - content_for :"#{post_id}_footer" do
+ = render 'bootstrap/btn', form: form_id, content: t('.save'), type: 'submit'
+ -# @todo: Volver al otro modal
+ = render 'bootstrap/btn', content: t('.cancel'), action: 'modal#hide'
diff --git a/app/views/posts/attributes/_new_content.haml b/app/views/posts/attributes/_new_content.haml
index cbdf8f94..bc3d1cb1 100644
--- a/app/views/posts/attributes/_new_content.haml
+++ b/app/views/posts/attributes/_new_content.haml
@@ -3,7 +3,7 @@
= render 'posts/attribute_feedback',
post: post, attribute: attribute, metadata: metadata
- .new-editor.content{ id: attribute }
+ .new-editor.content{ id: attribute, data: { controller: 'new-editor' } }
= text_area_tag "#{base}[#{attribute}]", metadata.to_s.html_safe,
- dir: dir, lang: locale,
+ dir: dir, lang: locale, 'data-new-editor-target': 'textarea',
**field_options(attribute, metadata), class: 'd-none'
diff --git a/app/views/posts/attributes/_new_has_and_belongs_to_many.haml b/app/views/posts/attributes/_new_has_and_belongs_to_many.haml
new file mode 100644
index 00000000..6959ecf7
--- /dev/null
+++ b/app/views/posts/attributes/_new_has_and_belongs_to_many.haml
@@ -0,0 +1,113 @@
+-#
+ Genera un listado de checkboxes entre los que se puede elegir para
+ guardar. Podemos elegir entre los artículos ya cargados o agregar uno
+ nuevo.
+
+ Al agregar uno nuevo, se abre un segundo modal que carga el formulario
+ correspondiente vía HTMX. El formulario tiene que cargarse por fuera
+ del formulario principal porque no se pueden anidar.
+
+:ruby
+ id = random_id
+ name = "#{base}[#{attribute}][]"
+ form_id = random_id
+ modal_id = random_id
+ post_id = random_id
+ post_form_id = random_id
+ post_modal_id = random_id
+ post_form_loaded_id = random_id
+ value_list_id = random_id
+ controllers = %w[modal array]
+ controllers << 'required-checkbox' if metadata.required
+
+%div{ id: modal_id, data: { controller: controllers.join(' '), 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_related_post_path(site, inverse: metadata.inverse) } }
+ %template{ data: { 'array-target': 'placeholder' } }
+ .col.p-3{ 'aria-hidden': 'true' }
+ %span.placeholder.w-100
+
+ .form-group
+ = hidden_field_tag name, ''
+ .d-flex.align-items-center.justify-content-between
+ %div
+ = label_tag id, post_label_t(attribute, post: post), class: 'h3'
+ = render 'posts/required_checkbox', required: metadata.required, name: name, initial: metadata.empty?
+ = render 'bootstrap/btn', content: t('.edit'), action: 'modal#show'
+
+ -# Mostramos la lista de valores actuales.
+
+ Al aceptar el modal, se vacía el listado y se completa en base a
+ renderizaciones con HTMX. Para poder hacer eso, tenemos que poder
+ acceder a todos los items dentro del modal (como array.item) y
+ enviar el valor al endpoint que devuelve uno por uno. Esto lo
+ tenemos disponible en Stimulus, pero queremos usar HTMX o técnica
+ similar para poder renderizar del lado del servidor.
+
+ Para poder cancelar, mantenemos el estado original y desactivamos
+ o activamos los ítemes según estén incluidos en esa lista o no.
+ .row.no-gutters.placeholder-glow{ data: { 'array-target': 'current' } }
+ -# @todo issue-7537
+ - metadata.value.each do |uuid|
+ - if (indexed_post = site.indexed_posts.find_by(post_id: uuid))
+ = render 'posts/new_related_post', post: indexed_post, attribute: metadata.type, inverse: metadata.inverse
+
+ = render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'], keydown_actions: %w[keydown->array#cancelWithEscape] do
+ - content_for :"#{id}_header" do
+ .form-group.flex-grow-1.mb-0
+ = label_tag id, post_label_t(attribute, post: post)
+ %input.form-control{ data: { 'array-target': 'search', action: 'input->array#search' }, type: 'search', placeholder: t('.filter') }
+
+ - content_for :"#{id}_body" do
+ .form-group.mb-0{ id: value_list_id }
+ - metadata.values.each_pair do |value, uuid|
+ = render 'targets/array/item', value: uuid, 'send-value': uuid, 'human-value': value, class: 'mb-2' do
+ = render 'bootstrap/custom_checkbox', name: name, id: random_id, value: uuid, checked: metadata.value.include?(uuid), content: value, data: { action: 'required-checkbox#change', 'required-checkbox-target': 'checkbox' }
+
+ -#
+ Según la definición del campo, si hay un filtro, tenemos que poder
+ elegir qué tipo de esquema queremos o si hay uno solo, siempre
+ vamos a enviar ese. Si no hay ninguno, tendríamos que poder elegir
+ entre todos los esquemas.
+
+ - content_for :"#{id}_footer" do
+ - layout = metadata.filter[:layout]
+ - if layout.is_a?(String)
+ %input{ type: 'hidden', name: 'layout', value: layout, form: post_form_id }
+ = render 'bootstrap/btn', content: t('.add', layout: site.layouts[layout].humanized_name), form: post_form_id, type: 'submit', class: 'm-0 mr-1'
+ - else
+ - layouts = layout&.map { |x| site.layouts[x] }
+ - layouts ||= site.layouts.values
+ .input-group.w-auto.flex-grow-1.my-0
+ %select.form-control{ form: post_form_id, name: 'layout' }
+ - layouts.each do |layout|
+ %option{ value: layout.name }= layout.humanized_name
+ .input-group-append
+ = render 'bootstrap/btn', content: t('.add', layout: ''), form: post_form_id, type: 'submit', class: 'mb-0 mr-0'
+ = render 'bootstrap/btn', content: t('.accept'), action: 'array#accept modal#hide', class: 'm-0 mr-1'
+ = render 'bootstrap/btn', content: t('.cancel'), action: 'array#cancel modal#hide', class: 'm-0'
+
+-#
+ Este segundo modal es el que carga los formularios de
+ creación/modificación de artículos relacionados. Se envía a post_form
+ para que sea externo al formulario actual.
+- content_for :post_form do
+ %form{ id: post_form_id, 'hx-get': site_posts_form_path(site), 'hx-target': "##{post_form_loaded_id}" }
+ %input{ type: 'hidden', name: 'modal_id', value: modal_id }
+ %input{ type: 'hidden', name: 'show', value: post_modal_id }
+ %input{ type: 'hidden', name: 'hide', value: modal_id }
+ %input{ type: 'hidden', name: 'target', value: value_list_id }
+ %input{ type: 'hidden', name: 'swap', value: 'beforeend' }
+ %input{ type: 'hidden', name: 'base', value: id }
+ %input{ type: 'hidden', name: 'name', value: name }
+ %input{ type: 'hidden', name: 'form', value: form_id }
+ %input{ type: 'hidden', name: 'attribute', value: metadata.type }
+ - if metadata.inverse?
+ %input{ type: 'hidden', name: 'inverse', value: metadata.inverse }
+ %input{ type: 'hidden', name: metadata.inverse, value: post.uuid.value }
+ %div{ id: post_modal_id, data: { controller: 'modal' } }
+ = render 'bootstrap/modal', id: post_id, modal_content_attributes: { class: 'h-100' } do
+ - content_for :"#{post_id}_body" do
+ %div{ id: post_form_loaded_id }
+ - content_for :"#{post_id}_footer" do
+ = render 'bootstrap/btn', form: form_id, content: t('.save'), type: 'submit'
+ -# @todo: Volver al otro modal
+ = render 'bootstrap/btn', content: t('.cancel'), action: 'modal#hide'
diff --git a/app/views/posts/attributes/_new_has_many.haml b/app/views/posts/attributes/_new_has_many.haml
new file mode 100644
index 00000000..c46bc84a
--- /dev/null
+++ b/app/views/posts/attributes/_new_has_many.haml
@@ -0,0 +1,113 @@
+-#
+ Genera un listado de checkboxes entre los que se puede elegir para
+ guardar. Podemos elegir entre los artículos ya cargados o agregar uno
+ nuevo.
+
+ Al agregar uno nuevo, se abre un segundo modal que carga el formulario
+ correspondiente vía HTMX. El formulario tiene que cargarse por fuera
+ del formulario principal porque no se pueden anidar.
+
+:ruby
+ id = id_for(base, attribute)
+ name = "#{base}[#{attribute}][]"
+ form_id = random_id
+ modal_id = random_id
+ post_id = random_id
+ post_form_id = random_id
+ post_modal_id = random_id
+ post_form_loaded_id = random_id
+ value_list_id = random_id
+ controllers = %w[modal array]
+ controllers << 'required-checkbox' if metadata.required
+
+%div{ id: modal_id, data: { controller: controllers.join(' '), 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_related_post_path(site) } }
+ %template{ data: { 'array-target': 'placeholder' } }
+ .col.p-3{ 'aria-hidden': 'true' }
+ %span.placeholder.w-100
+
+ .form-group
+ .d-flex.align-items-center.justify-content-between
+ %div
+ = label_tag id, post_label_t(attribute, post: post), class: 'h3'
+ = render 'posts/required_checkbox', required: metadata.required, name: name, initial: metadata.empty?
+ = render 'bootstrap/btn', content: t('.edit'), action: 'modal#show'
+
+ -# Mostramos la lista de valores actuales.
+
+ Al aceptar el modal, se vacía el listado y se completa en base a
+ renderizaciones con HTMX. Para poder hacer eso, tenemos que poder
+ acceder a todos los items dentro del modal (como array.item) y
+ enviar el valor al endpoint que devuelve uno por uno. Esto lo
+ tenemos disponible en Stimulus, pero queremos usar HTMX o técnica
+ similar para poder renderizar del lado del servidor.
+
+ Para poder cancelar, mantenemos el estado original y desactivamos
+ o activamos los ítemes según estén incluidos en esa lista o no.
+ .row.no-gutters.placeholder-glow{ data: { 'array-target': 'current' } }
+ -# @todo issue-7537
+ - metadata.value.each do |uuid|
+ - if (indexed_post = site.indexed_posts.find_by(post_id: uuid))
+ = render 'posts/new_related_post', post: indexed_post, attribute: metadata.type, inverse: metadata.inverse
+
+ = render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'], keydown_actions: %w[keydown->array#cancelWithEscape] do
+ - content_for :"#{id}_header" do
+ .form-group.flex-grow-1.mb-0
+ = label_tag id, post_label_t(attribute, post: post)
+ %input.form-control{ data: { 'array-target': 'search', action: 'input->array#search' }, type: 'search', placeholder: t('.filter') }
+
+ - content_for :"#{id}_body" do
+ .form-group.mb-0{ id: value_list_id }
+ - metadata.values.each do |(value, uuid, disabled)|
+ = render 'targets/array/item', value: uuid, 'send-value': uuid, 'human-value': value, class: 'mb-2' do
+ = render 'bootstrap/custom_checkbox', name: name, id: random_id, value: uuid, checked: metadata.value.include?(uuid), content: value, data: { action: 'required-checkbox#change', 'required-checkbox-target': 'checkbox' }, disabled: disabled do
+ = t('posts.attributes.new_has_many.disabled') if disabled
+
+ -#
+ Según la definición del campo, si hay un filtro, tenemos que poder
+ elegir qué tipo de esquema queremos o si hay uno solo, siempre
+ vamos a enviar ese. Si no hay ninguno, tendríamos que poder elegir
+ entre todos los esquemas.
+
+ - content_for :"#{id}_footer" do
+ - layout = metadata.filter[:layout]
+ - if layout.is_a?(String)
+ %input{ type: 'hidden', name: 'layout', value: layout, form: post_form_id }
+ = render 'bootstrap/btn', content: t('.add', layout: site.layouts[layout].humanized_name), form: post_form_id, type: 'submit', class: 'm-0 mr-1'
+ - else
+ - layouts = layout&.map { |x| site.layouts[x] }
+ - layouts ||= site.layouts.values
+ .input-group.w-auto.flex-grow-1.my-0
+ %select.form-control{ form: post_form_id, name: 'layout' }
+ - layouts.each do |layout|
+ %option{ value: layout.name }= layout.humanized_name
+ .input-group-append
+ = render 'bootstrap/btn', content: t('.add', layout: ''), form: post_form_id, type: 'submit', class: 'mb-0 mr-0'
+ = render 'bootstrap/btn', content: t('.accept'), action: 'array#accept modal#hide', class: 'm-0 mr-1'
+ = render 'bootstrap/btn', content: t('.cancel'), action: 'array#cancel modal#hide', class: 'm-0'
+
+-#
+ Este segundo modal es el que carga los formularios de
+ creación/modificación de artículos relacionados. Se envía a post_form
+ para que sea externo al formulario actual.
+- content_for :post_form do
+ %form{ id: post_form_id, 'hx-get': site_posts_form_path(site), 'hx-target': "##{post_form_loaded_id}" }
+ %input{ type: 'hidden', name: 'show', value: post_modal_id }
+ %input{ type: 'hidden', name: 'hide', value: modal_id }
+ %input{ type: 'hidden', name: 'target', value: value_list_id }
+ %input{ type: 'hidden', name: 'swap', value: 'beforeend' }
+ %input{ type: 'hidden', name: 'base', value: id }
+ %input{ type: 'hidden', name: 'name', value: name }
+ %input{ type: 'hidden', name: 'form', value: form_id }
+ %input{ type: 'hidden', name: 'attribute', value: metadata.type }
+ -# @todo Forma genérica de arrastrar valores desde un formulario al siguiente
+ - if metadata.inverse?
+ %input{ type: 'hidden', name: 'inverse', value: metadata.inverse }
+ %input{ type: 'hidden', name: metadata.inverse, value: post.uuid.value }
+ %div{ id: post_modal_id, data: { controller: 'modal' } }
+ = render 'bootstrap/modal', id: post_id, modal_content_attributes: { class: 'h-100' } do
+ - content_for :"#{post_id}_body" do
+ %div{ id: post_form_loaded_id }
+ - content_for :"#{post_id}_footer" do
+ = render 'bootstrap/btn', form: form_id, content: t('.save'), type: 'submit'
+ -# @todo: Volver al otro modal
+ = render 'bootstrap/btn', content: t('.cancel'), action: 'modal#hide'
diff --git a/app/views/posts/attributes/_new_has_one.haml b/app/views/posts/attributes/_new_has_one.haml
new file mode 100644
index 00000000..573cd37e
--- /dev/null
+++ b/app/views/posts/attributes/_new_has_one.haml
@@ -0,0 +1,61 @@
+-#
+ Genera un listado de radios entre los que se puede elegir solo uno para
+ guardar. Podemos elegir entre los artículos ya cargados o agregar uno
+ nuevo.
+
+ Al agregar uno nuevo, se abre un segundo modal que carga el formulario
+ correspondiente vía HTMX. El formulario tiene que cargarse por fuera
+ del formulario principal porque no se pueden anidar.
+
+:ruby
+ id = random_id
+ name = "#{base}[#{attribute}]"
+ target_id = random_id
+ form_id = random_id
+ modal_id = random_id
+ post_id = random_id
+ post_form_id = random_id
+ post_modal_id = random_id
+ post_form_loaded_id = random_id
+ value_list_id = random_id
+ layout = metadata.filter[:layout]
+ invalid_id = random_id
+ submitting_id = random_id
+ saved_id = random_id
+
+%div{ data: { controller: 'modal' }}
+ .form-group
+ = hidden_field_tag name, ''
+ .d-flex.align-items-center.justify-content-between
+ = label_tag id, post_label_t(attribute, post: post), class: 'h3'
+ = render 'bootstrap/btn', content: t('.edit'), data: { action: 'modal#showAnother', 'modal-show-value': modal_id }, id: random_id
+
+ -# Aquí se reemplaza por la tarjeta y el UUID luego de guardar
+ .row.no-gutters.placeholder-glow{ id: target_id }
+ -# @todo issue-7537
+ - if !metadata.empty? && (indexed_post = site.indexed_posts.find_by(post_id: metadata.value))
+ = render 'posts/new_has_one', post: indexed_post, name: name, value: metadata.value, modal_id: modal_id
+
+-#
+ El modal se genera por fuera del formulario, para poder enviar los
+ datos y recibir su UUID en respuesta.
+- content_for :post_form do
+ %div{ id: modal_id, data: { controller: 'modal' }}
+ -# Si hay un solo layout o el post asociado ya existía
+ - if layout.is_a?(String) || metadata.has_one.present?
+ = render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' } do
+ - content_for :"#{id}_body" do
+ -# @todo ocultar el modal después de guardar
+ .placeholder-glow{ 'hx-get': site_posts_form_path(site, layout: layout, base: id, name: name, form: form_id, swap: 'innerHTML', target: target_id, attribute: 'new_has_one', modal_id: modal_id, uuid: metadata.value, invalid: invalid_id, submitting: submitting_id, saved: saved_id, inverse: metadata.inverse, metadata.inverse => post.uuid.value), 'hx-trigger': 'load' }
+ %span.placeholder.w-100.h-100
+
+ - content_for :"#{id}_footer" do
+ = render 'posts/validation', site: site, invalid: { id: invalid_id }, submitting: { id: submitting_id }
+ = render 'bootstrap/alert', class: 'm-0 d-none fade', id: saved_id, data: { controller: 'notification', action: 'notification:show@window->notification#show', 'notification-hide-class': 'hide', 'notification-show-class': 'show' } do
+ = t('.saved')
+ = render 'bootstrap/btn', form: form_id, content: t('.save'), type: 'submit', class: 'm-0 mt-1 mr-1'
+ = render 'bootstrap/btn', content: t('.close'), action: 'modal#hide', class: 'm-0 mt-1 mr-1'
+
+ - else
+ -# @todo Implementar selección de layout para cargar el formulario correcto
+ Nada
diff --git a/app/views/posts/attributes/_new_predefined_array.haml b/app/views/posts/attributes/_new_predefined_array.haml
new file mode 100644
index 00000000..0afa253d
--- /dev/null
+++ b/app/views/posts/attributes/_new_predefined_array.haml
@@ -0,0 +1,57 @@
+-#
+ Genera un listado de checkboxes entre los que se puede elegir para
+ guardar, pero no se pueden agregar nuevos.
+
+:ruby
+ id = id_for(base, attribute)
+ name = "#{base}[#{attribute}][]"
+ form_id = random_id
+ controllers = %w[modal array]
+ controllers << 'required-checkbox' if metadata.required
+
+%div{ data: { controller: controllers.join(' '), 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_array_value_path(site) } }
+ %template{ data: { 'array-target': 'placeholder' } }
+ .col.mb-3{ 'aria-hidden': 'true' }
+ %span.placeholder.w-100
+
+ .form-group
+ = hidden_field_tag name, ''
+ .d-flex.align-items-center.justify-content-between
+ %div
+ = label_tag id, post_label_t(attribute, post: post), class: 'h3'
+ = render 'posts/required_checkbox', required: metadata.required, name: name, initial: metadata.empty?
+ = render 'bootstrap/btn', content: t('.edit'), action: 'modal#show'
+
+ -# Mostramos la lista de valores actuales.
+
+ Al aceptar el modal, se vacía el listado y se completa en base a
+ renderizaciones con HTMX. Para poder hacer eso, tenemos que poder
+ acceder a todos los items dentro del modal (como array.item) y
+ enviar el valor al endpoint que devuelve uno por uno. Esto lo
+ tenemos disponible en Stimulus, pero queremos usar HTMX o técnica
+ similar para poder renderizar del lado del servidor.
+
+ Para poder cancelar, mantenemos el estado original y desactivamos
+ o activamos los ítemes según estén incluidos en esa lista o no.
+ %ul.placeholder-glow{ data: { 'array-target': 'current' } }
+ - metadata.values.invert.slice(*metadata.value).each_value do |value|
+ = render 'posts/new_array_value', value: value
+
+ = render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'], keydown_actions: %w[keydown->array#cancelWithEscape] do
+ - content_for :"#{id}_header" do
+ .form-group.flex-grow-1.mb-0
+ = label_tag id, post_label_t(attribute, post: post)
+ %input.form-control{ data: { 'array-target': 'search', action: 'input->array#search' }, type: 'search', placeholder: t('.filter') }
+
+ - content_for :"#{id}_body" do
+ .form-group.mb-0{ id: "#{id}_body" }
+ -# Eliminamos las tildes para poder buscar independientemente de cómo se escriba
+ - metadata.values.each_pair do |value, key|
+ = render 'targets/array/item', class: 'mb-2', value: key, 'human-value': value do
+ = render 'bootstrap/custom_checkbox', name: name, id: random_id, value: key, checked: metadata.value.include?(key), content: value, data: { action: 'required-checkbox#change', 'required-checkbox-target': 'checkbox' }
+
+ - content_for :"#{id}_footer" do
+ -# Alinear los botones a la derecha
+ .flex-grow-1
+ = render 'bootstrap/btn', content: t('.accept'), action: 'array#accept modal#hide', class: 'm-0 mr-1'
+ = render 'bootstrap/btn', content: t('.cancel'), action: 'array#cancel modal#hide', class: 'm-0'
diff --git a/app/views/posts/attributes/_new_predefined_value.haml b/app/views/posts/attributes/_new_predefined_value.haml
new file mode 100644
index 00000000..20bd5d0b
--- /dev/null
+++ b/app/views/posts/attributes/_new_predefined_value.haml
@@ -0,0 +1,65 @@
+-#
+ Genera un listado de radios entre los que se puede elegir solo uno para
+ guardar. Podemos elegir entre los artículos ya cargados o agregar uno
+ nuevo.
+
+ Al agregar uno nuevo, se abre un segundo modal que carga el formulario
+ correspondiente vía HTMX. El formulario tiene que cargarse por fuera
+ del formulario principal porque no se pueden anidar.
+
+:ruby
+ id = id_for(base, attribute)
+ name = "#{base}[#{attribute}]"
+ form_id = random_id
+ modal_id = random_id
+ post_id = random_id
+ post_form_id = random_id
+ post_modal_id = random_id
+ post_form_loaded_id = random_id
+ value_list_id = random_id
+ controllers = %w[modal array]
+ controllers << 'required-checkbox' if metadata.required
+
+%div{ id: modal_id, data: { controller: controllers.join(' '), 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_array_value_path(site) } }
+ %template{ data: { 'array-target': 'placeholder' } }
+ .col.p-3{ 'aria-hidden': 'true' }
+ %span.placeholder.w-100
+
+ .form-group
+ = hidden_field_tag name, ''
+ .d-flex.align-items-center.justify-content-between
+ %div
+ = label_tag id, post_label_t(attribute, post: post), class: 'h3'
+ = render 'posts/required_checkbox', required: metadata.required, name: name, initial: metadata.empty?, type: 'radio'
+ = render 'bootstrap/btn', content: t('.edit'), action: 'modal#show'
+
+ -# Mostramos la lista de valores actuales.
+
+ Al aceptar el modal, se vacía el listado y se completa en base a
+ renderizaciones con HTMX. Para poder hacer eso, tenemos que poder
+ acceder a todos los items dentro del modal (como array.item) y
+ enviar el valor al endpoint que devuelve uno por uno. Esto lo
+ tenemos disponible en Stimulus, pero queremos usar HTMX o técnica
+ similar para poder renderizar del lado del servidor.
+
+ Para poder cancelar, mantenemos el estado original y desactivamos
+ o activamos los ítemes según estén incluidos en esa lista o no.
+ %ul.list-unstyled.px-3.font-weight-bold.placeholder-glow{ data: { 'array-target': 'current' } }
+ - unless metadata.empty?
+ = render 'posts/new_array_value', value: metadata.to_s
+
+ = render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'], keydown_actions: %w[keydown->array#cancelWithEscape] do
+ - content_for :"#{id}_header" do
+ .form-group.flex-grow-1.mb-0
+ = label_tag id, post_label_t(attribute, post: post)
+ %input.form-control{ data: { 'array-target': 'search', action: 'input->array#search' }, type: 'search', placeholder: t('.filter') }
+
+ - content_for :"#{id}_body" do
+ .form-group.mb-0{ id: value_list_id }
+ - metadata.values.each_pair do |value, key|
+ = render 'targets/array/item', value: value, class: 'mb-2' do
+ = render 'bootstrap/custom_checkbox', name: name, id: random_id, value: key, checked: (metadata.value == key), content: value, type: 'radio'
+
+ - content_for :"#{id}_footer" do
+ = render 'bootstrap/btn', content: t('.accept'), action: 'array#accept modal#hide', class: 'm-0 mr-1'
+ = render 'bootstrap/btn', content: t('.cancel'), action: 'array#cancel modal#hide', class: 'm-0'
diff --git a/app/views/posts/attributes/_non_geo.haml b/app/views/posts/attributes/_non_geo.haml
index 3f6a75a6..37bdcb39 100644
--- a/app/views/posts/attributes/_non_geo.haml
+++ b/app/views/posts/attributes/_non_geo.haml
@@ -1,5 +1,5 @@
.row{ data: { controller: 'non-geo', site: site.url } }
- .d-none{ hidden: true, data: { target: 'non-geo.overlay' }}
+ .d-none{ hidden: true, data: { 'non-geo-target': 'overlay' }}
.col-12.mb-3
%p.mb-0= post_label_t(attribute, post: post)
%p= post_label_t(attribute, post: post)
@@ -12,7 +12,7 @@
= text_field(*field_name_for(base, attribute, :lat),
value: metadata.value['lat'],
**field_options(attribute, metadata),
- data: { target: 'non-geo.lat' })
+ data: { 'non-geo-target': 'lat' })
= render 'posts/attribute_feedback',
post: post, attribute: [attribute, :lat], metadata: metadata
.col
@@ -22,8 +22,8 @@
= text_field(*field_name_for(base, attribute, :lng),
value: metadata.value['lng'],
**field_options(attribute, metadata),
- data: { target: 'non-geo.lng' })
+ data: { 'non-geo-target': 'lng' })
= render 'posts/attribute_feedback',
post: post, attribute: [attribute, :lng], metadata: metadata
.col-12.mb-3
- %div{ data: { target: 'non-geo.map' }, style: 'height: 250px' }
+ %div{ data: { 'non-geo-target': 'map' }, style: 'height: 250px' }
diff --git a/app/views/posts/attributes/_permalink.haml b/app/views/posts/attributes/_permalink.haml
index aa033643..ab951cbe 100644
--- a/app/views/posts/attributes/_permalink.haml
+++ b/app/views/posts/attributes/_permalink.haml
@@ -1,7 +1,8 @@
-.form-group
- = label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
- = text_field base, attribute, value: metadata.value,
- dir: dir, lang: locale,
- **field_options(attribute, metadata)
- = render 'posts/attribute_feedback',
- post: post, attribute: attribute, metadata: metadata
+- if post.written?
+ .form-group
+ = label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
+ = text_field base, attribute, value: metadata.value,
+ dir: dir, lang: locale,
+ **field_options(attribute, metadata)
+ = render 'posts/attribute_feedback',
+ post: post, attribute: attribute, metadata: metadata
diff --git a/app/views/posts/attributes/_plain_text.haml b/app/views/posts/attributes/_plain_text.haml
new file mode 100644
index 00000000..3f9ae91d
--- /dev/null
+++ b/app/views/posts/attributes/_plain_text.haml
@@ -0,0 +1,8 @@
+.form-group
+ = label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
+ = render 'posts/attribute_feedback',
+ post: post, attribute: attribute, metadata: metadata
+
+ = text_area_tag "#{base}[#{attribute}]", metadata.to_s.html_safe,
+ dir: dir, lang: locale,
+ **field_options(attribute, metadata)
diff --git a/_deploy/.keep b/app/views/posts/attributes/_title.haml
similarity index 100%
rename from _deploy/.keep
rename to app/views/posts/attributes/_title.haml
diff --git a/app/views/posts/attributes/_uuid.haml b/app/views/posts/attributes/_uuid.haml
index 0aab9802..60d74cfc 100644
--- a/app/views/posts/attributes/_uuid.haml
+++ b/app/views/posts/attributes/_uuid.haml
@@ -1 +1 @@
--# nada
+= hidden_field_tag "#{base}[#{attribute}]", metadata.value
diff --git a/app/views/posts/edit.haml b/app/views/posts/edit.haml
index e7e0260d..d03e08c7 100644
--- a/app/views/posts/edit.haml
+++ b/app/views/posts/edit.haml
@@ -1,9 +1,3 @@
.row.justify-content-center
- .col-md-8
- - if policy(@site).edit?
- = render 'layouts/details', summary: t('posts.edit.post') do
- = render 'posts/form', site: @site, post: @post
- = render 'layouts/details', summary: t('posts.edit.moderation_queue') do
- = render 'posts/moderation_queue', site: @site, post: @post, moderation_queue: @moderation_queue
- - else
- = render 'posts/form', site: @site, post: @post
+ .col-12.col-lg-8
+ = render 'posts/form', site: @site, post: @post
diff --git a/app/views/posts/form.haml b/app/views/posts/form.haml
new file mode 100644
index 00000000..bb63aaeb
--- /dev/null
+++ b/app/views/posts/form.haml
@@ -0,0 +1,7 @@
+-#
+ El formulario sin ninguna decoración, para incluir dentro de otros
+ elementos.
+
+ @param :site [Site]
+ @param :post [Post]
+= render 'posts/htmx_form', site: @site, post: @post, locale: @locale, dir: t("locales.#{@locale}.dir"), base: pluck_param(:base)
diff --git a/app/views/posts/index.haml b/app/views/posts/index.haml
index 3de30aa3..27e74889 100644
--- a/app/views/posts/index.haml
+++ b/app/views/posts/index.haml
@@ -1,12 +1,12 @@
- reorder_allowed = policy(@site).reorder?
- if reorder_allowed
- reorder_controller = { controller: 'reorder' }
- - reorder_target = { target: 'reorder.row' }
+ - reorder_target = { 'reorder-target': 'row' }
- else
- reorder_target = reorder_controller = {}
%main.row
- %aside.menu.col-md-3
+ %aside.menu.col-lg-3
.mb-3
= render 'sites/header', site: @site
= render 'sites/status', site: @site
@@ -16,7 +16,7 @@
%h3= t('posts.new')
%table.table.table-sm.mb-3
%tbody
- - @site.schema_organization.each do |schema, _|
+ - @site.schema_organization(current_usuarie).each do |schema, _|
- schema = @site.layouts[schema]
- next if schema.hidden?
= render 'schemas/row', site: @site, schema: schema, filter: @filter_params
@@ -85,11 +85,12 @@
= submit_tag t('posts.reorder.submit'), class: 'btn btn-secondary'
%button.btn.btn-secondary{ data: { action: 'reorder#unselect' } }
= t('posts.reorder.unselect')
- %span.badge{ data: { target: 'reorder.counter' } } 0
+ %span.badge{ data: { 'reorder-target': 'counter' } } 0
%button.btn.btn-secondary{ data: { action: 'reorder#up' } }= t('posts.reorder.up')
%button.btn.btn-secondary{ data: { action: 'reorder#down' } }= t('posts.reorder.down')
%button.btn.btn-secondary{ data: { action: 'reorder#top' } }= t('posts.reorder.top')
%button.btn.btn-secondary{ data: { action: 'reorder#bottom' } }= t('posts.reorder.bottom')
+ %input{ type: 'hidden', name: 'post[lang]', value: @locale }
- if @site.pagination
%div
@@ -99,45 +100,53 @@
- dir = @site.data.dig(params[:locale], 'dir')
- size = @posts.size
- @posts.each_with_index do |post, i|
+ -# @todo issue-7537
+ - next if @site.layouts[post.layout].hidden?
-#
TODO: Solo les usuaries cachean porque tenemos que separar
les botones por permisos.
- - cache_if @usuarie, [post, I18n.locale] do
- - checkbox_id = "checkbox-#{post.post_id}"
- %tr{ id: post.post_id, data: reorder_target }
- - if reorder_allowed
- %td
- .custom-control.custom-checkbox
- %input.custom-control-input{ id: checkbox_id, type: 'checkbox', autocomplete: 'off', data: { action: 'reorder#select' } }
- %label.custom-control-label{ for: checkbox_id }
- %span.sr-only= t('posts.reorder.select')
- -# Orden más alto es mayor prioridad
- = hidden_field 'post[reorder]', post.post_id,
- value: size - i,
- data: { reorder: true }
- %td.w-100{ class: dir }
- = link_to site_post_path(@site, post.path) do
- %span{ lang: post.locale, dir: dir }= post.title
- - if post.front_matter['draft'].present?
- %span.badge.badge-primary= I18n.t('posts.attributes.draft.label')
- %br
- %small
- = link_to @site.layouts[post.layout].humanized_name, site_posts_path(@site, **@filter_params.merge(layout: post.layout))
- - post.front_matter['categories']&.each do |category|
- = link_to site_posts_path(@site, **@filter_params.merge(category: category)) do
- %span{ lang: post.locale, dir: dir }= category
- = '/' unless post.front_matter['categories'].last == category
+ - begin
+ - cache_if @usuarie, [post, I18n.locale] do
+ - checkbox_id = "checkbox-#{post.post_id}"
+ %tr{ id: post.post_id, data: reorder_target }
+ - if reorder_allowed
+ %td
+ .custom-control.custom-checkbox
+ %input.custom-control-input{ id: checkbox_id, type: 'checkbox', autocomplete: 'off', data: { action: 'reorder#select' } }
+ %label.custom-control-label{ for: checkbox_id }
+ %span.sr-only= t('posts.reorder.select')
+ -# Orden más alto es mayor prioridad
+ = hidden_field 'post[reorder]', post.post_id,
+ value: size - i,
+ data: { reorder: true }
+ %td.w-100{ class: dir }
+ = link_to site_post_path(@site, post.path) do
+ %span{ lang: post.locale, dir: dir }= post.title
+ - if post.front_matter['draft'].present?
+ %span.badge.badge-primary= I18n.t('posts.attributes.draft.label')
+ %br
+ %small
+ = link_to @site.layouts[post.layout].humanized_name, site_posts_path(@site, **@filter_params.merge(layout: post.layout))
+ - post.front_matter['categories']&.each do |category|
+ = link_to site_posts_path(@site, **@filter_params.merge(category: category)) do
+ %span{ lang: post.locale, dir: dir }= category
+ = '/' unless post.front_matter['categories'].last == category
- %td.text-nowrap
- = post.created_at.strftime('%F')
- %br/
- = post.order
- %td.text-nowrap
- .d-flex.flex-row.align-items-start
- - if @usuarie || policy(post).edit?
- = link_to t('posts.edit_post'), edit_site_post_path(@site, post.path), class: 'btn btn-secondary'
- - if @usuarie || policy(post).destroy?
- = link_to t('posts.destroy'), site_post_path(@site, post.path), class: 'btn btn-secondary', method: :delete, data: { confirm: t('posts.confirm_destroy') }
+ %td.text-nowrap
+ = post.created_at.strftime('%F')
+ %br/
+ = post.order
+ %td.text-nowrap
+ .d-flex.flex-row.align-items-start
+ - if @usuarie || policy(post).edit?
+ = link_to t('posts.edit'), edit_site_post_path(@site, post.path), class: 'btn btn-secondary'
+ - if @usuarie || policy(post).destroy?
+ = link_to t('posts.destroy'), site_post_path(@site, post.path), class: 'btn btn-secondary', method: :delete, data: { confirm: t('posts.confirm_destroy') }
+ -#
+ Rescatar cualquier error en un post, notificarlo e
+ ignorar su renderización.
+ - rescue ActionView::Template::Error => e
+ - ExceptionNotifier.notify_exception(e.cause, data: { site: @site.name, post: @post.path.absolute, usuarie: current_usuarie.id })
#footnotes{ hidden: true }
- @filter_params.each do |param, value|
diff --git a/app/views/posts/modal.haml b/app/views/posts/modal.haml
new file mode 100644
index 00000000..93d7e6a5
--- /dev/null
+++ b/app/views/posts/modal.haml
@@ -0,0 +1,71 @@
+-#
+
+ Genera un modal completo con el formulario del post y sus botones de
+ guardado.
+
+ Se comporta como "HTMX".
+
+
+:ruby
+ post = @post
+ site = post.site
+ locale = @locale
+ base = random_id
+ dir = t("locales.#{locale}.dir")
+ modal_id = pluck_param(:modal_id)
+ result_id = pluck_param(:result_id)
+ form_id = random_id
+ except = %i[date]
+
+ if (inverse = pluck_param(:inverse, optional: true))
+ except << inverse.to_sym
+ end
+
+ options = {
+ id: form_id,
+ multipart: true,
+ class: 'form post'
+ }
+
+ if post.new?
+ url = options[:'hx-post'] = site_posts_path(site, locale: locale)
+ options[:class] += ' new'
+ else
+ url = options[:'hx-patch'] = site_post_path(site, post.id, locale: locale)
+ options[:method] = :patch
+ options[:class] += ' edit'
+ end
+
+ data = {}
+ data[:controller] = 'unsaved-changes form-validation'
+ data[:action] = 'unsaved-changes#submit form-validation#submit beforeunload@window->unsaved-changes#unsaved turbolinks:before-visit@window->unsaved-changes#unsavedTurbolinks'
+
+ options[:data] = data
+
+%div{ id: modal_id, data: { controller: 'modal' }}
+ = render 'bootstrap/modal', id: modal_id, modal_content_attributes: { class: 'h-100' } do
+ - content_for :"#{modal_id}_body" do
+ = form_tag url, **options do
+ = hidden_field_tag 'base', base
+ = hidden_field_tag 'result_id', result_id
+ = hidden_field_tag 'modal_id', modal_id
+ = hidden_field_tag "#{base}[layout]", post.layout.name
+ = hidden_field_tag 'hide', pluck_param((post.errors.empty? ? :show : :hide), optional: true) || pluck_param(:modal_id, optional: true)
+ = hidden_field_tag 'show', pluck_param((post.errors.empty? ? :hide : :show), optional: true)
+ - if inverse
+ = hidden_field_tag 'inverse', inverse
+
+ = render 'errors', post: post
+ = render 'posts/attributes', site: site, post: post, dir: dir, base: base, locale: locale, except: except
+ -# @todo Volver obligatorios?
+ - except.each do |attr|
+ - if (value = pluck_param(attr, optional: true)).present?
+ = hidden_field_tag "#{base}[#{attr}]", value
+
+ - content_for :"#{modal_id}_footer" do
+ -# = render 'posts/validation', site: site, invalid: { id: invalid_id }, submitting: { id: submitting_id }
+ -# = render 'bootstrap/alert', class: 'm-0 d-none fade', id: saved_id, data: { controller: 'notification', action: 'notification:show@window->notification#show', 'notification-hide-class': 'hide', 'notification-show-class': 'show' } do
+ = t('.saved')
+ = render 'bootstrap/btn', form: form_id, content: t('.save'), type: 'submit', class: 'm-0 mt-1 mr-1'
+ = render 'bootstrap/btn', content: t('.close'), action: 'modal#hide', class: 'm-0 mt-1 mr-1'
+ = yield(:post_form)
diff --git a/app/views/posts/new.haml b/app/views/posts/new.haml
index 6ec252fe..d03e08c7 100644
--- a/app/views/posts/new.haml
+++ b/app/views/posts/new.haml
@@ -1,3 +1,3 @@
.row.justify-content-center
- .col-md-8
+ .col-12.col-lg-8
= render 'posts/form', site: @site, post: @post
diff --git a/app/views/posts/new_array.haml b/app/views/posts/new_array.haml
new file mode 100644
index 00000000..7fd52a53
--- /dev/null
+++ b/app/views/posts/new_array.haml
@@ -0,0 +1,8 @@
+- item_id = random_id
+
+= render 'targets/array/item', value: @value, class: 'mb-2', id: item_id do
+ .d-flex.flex-row.flex-wrap
+ .flex-grow-1
+ = render 'bootstrap/custom_checkbox', name: @name, id: random_id, value: @value, checked: true, content: @value, data: { action: 'required-checkbox#change', 'required-checkbox-target': 'checkbox' }
+ %div
+ %button.btn.btn-sm.m-0{ data: { action: 'array#remove', 'remove-target-param': item_id } }= t('.remove')
diff --git a/app/views/posts/new_array_value.haml b/app/views/posts/new_array_value.haml
new file mode 100644
index 00000000..c71ed3b5
--- /dev/null
+++ b/app/views/posts/new_array_value.haml
@@ -0,0 +1 @@
+= render 'posts/new_array_value', value: @value
diff --git a/app/views/posts/new_belongs_to_value.haml b/app/views/posts/new_belongs_to_value.haml
new file mode 100644
index 00000000..c7917b7d
--- /dev/null
+++ b/app/views/posts/new_belongs_to_value.haml
@@ -0,0 +1,2 @@
+= render 'targets/array/item', value: @uuid, 'send-value': @uuid, 'human-value': @value, class: 'mb-2' do
+ = render 'bootstrap/custom_checkbox', name: @name, id: random_id, value: @uuid, checked: true, content: @value, type: 'radio'
diff --git a/app/views/posts/new_has_many_value.haml b/app/views/posts/new_has_many_value.haml
new file mode 100644
index 00000000..7204a268
--- /dev/null
+++ b/app/views/posts/new_has_many_value.haml
@@ -0,0 +1,2 @@
+= render 'targets/array/item', value: @uuid, 'send-value': @uuid, 'human-value': @value, class: 'mb-2' do
+ = render 'bootstrap/custom_checkbox', name: @name, id: random_id, value: @uuid, checked: true, content: @value, data: { action: 'required-checkbox#change', 'required-checkbox-target': 'checkbox' }
diff --git a/app/views/posts/new_has_one.haml b/app/views/posts/new_has_one.haml
new file mode 100644
index 00000000..8f65ff08
--- /dev/null
+++ b/app/views/posts/new_has_one.haml
@@ -0,0 +1 @@
+= render 'posts/new_has_one', post: @indexed_post, name: pluck_param(:name), value: @uuid
diff --git a/app/views/posts/new_has_one_value.haml b/app/views/posts/new_has_one_value.haml
new file mode 100644
index 00000000..9ce50526
--- /dev/null
+++ b/app/views/posts/new_has_one_value.haml
@@ -0,0 +1 @@
+= render 'posts/new_has_one', post: @post.to_index, name: pluck_param(:name), value: @uuid, modal_id: pluck_param(:modal_id)
diff --git a/app/views/posts/new_related_post.haml b/app/views/posts/new_related_post.haml
new file mode 100644
index 00000000..1b173fa9
--- /dev/null
+++ b/app/views/posts/new_related_post.haml
@@ -0,0 +1 @@
+= render 'posts/new_related_post', post: @indexed_post, modal_id: pluck_param(:modal_id, optional: true), inverse: pluck_param(:inverse, optional: true)
diff --git a/app/views/posts/show.haml b/app/views/posts/show.haml
index 10fe64e3..e636aa4a 100644
--- a/app/views/posts/show.haml
+++ b/app/views/posts/show.haml
@@ -1,33 +1,10 @@
- dir = @site.data.dig(params[:locale], 'dir')
+
.row.justify-content-center
- .col-md-8
+ .col-12.col-lg-8
%article.content.table-responsive-md
= link_to t('posts.edit_post'),
- edit_site_post_path(@site, @post.id),
- class: 'btn btn-secondary btn-block'
+ edit_site_post_path(@site, @post.id),
+ class: 'btn btn-secondary btn-block'
- %table.table.table-condensed
- %thead
- %tr
- %th.text-center{ colspan: 2 }= t('.front_matter')
- %tbody
- - @post.attributes.each do |attr|
- - metadata = @post[attr]
- - next unless metadata.front_matter?
-
- - cache [metadata, I18n.locale] do
- = render("posts/attribute_ro/#{metadata.type}",
- post: @post, attribute: attr,
- metadata: metadata,
- site: @site,
- locale: @locale,
- dir: dir)
-
- -# Mostrar todo lo que no va en el front_matter (el contenido)
- - @post.attributes.each do |attr|
- - metadata = @post[attr]
- - next if metadata.front_matter?
-
- - cache [metadata, I18n.locale] do
- %section.content.pb-3{ id: attr, dir: dir }
- = @post.public_send(attr).to_s.html_safe
+ = render 'table', dir: dir, site: @site, locale: @locale, post: @post, title: t('.front_matter')
diff --git a/app/views/sites/_form.haml b/app/views/sites/_form.haml
index 8d909d79..377b43b4 100644
--- a/app/views/sites/_form.haml
+++ b/app/views/sites/_form.haml
@@ -64,7 +64,7 @@
disabled: design.disabled,
required: true, class: 'custom-control-input'
= f.label "design_id_#{design.id}", design.name,
- class: 'custom-control-label'
+ class: 'custom-control-label font-weight-bold'
.flex-fill
= sanitize_markdown design.description,
tags: %w[p a strong em]
@@ -93,7 +93,7 @@
= f.radio_button :licencia_id, licencia.id,
checked: licencia.id == site.licencia_id,
required: true, class: 'custom-control-input'
- = f.label "licencia_id_#{licencia.id}", class: 'custom-control-label' do
+ = f.label "licencia_id_#{licencia.id}", class: 'custom-control-label font-weight-bold' do
= licencia.name
= sanitize_markdown licencia.description,
tags: %w[p a strong em ul ol li h1 h2 h3 h4 h5 h6]
@@ -158,9 +158,10 @@
%h2= t('.deploys.title')
%p.lead= t('.help.deploys')
- = f.fields_for :deploys do |deploy|
- = render "deploys/#{deploy.object.type.underscore}",
- deploy: deploy, site: site
+ - site.deployment_list.each do |deploy|
+ = f.fields_for :deploys, deploy do |deploy_fields|
+ = render "deploys/#{deploy.type.underscore}",
+ deploy: deploy_fields, site: site
.form-group
= f.submit submit, class: 'btn btn-secondary btn-lg btn-block'
diff --git a/app/views/sites/build.haml b/app/views/sites/build.haml
index c2becec0..2743c265 100644
--- a/app/views/sites/build.haml
+++ b/app/views/sites/build.haml
@@ -1 +1 @@
-= render 'sites/build', site: @site, class: params.permit(:class)[:class]
+= render 'sites/build', site: @site, class: pluck_param(:class, optional: true)
diff --git a/app/views/sites/edit.haml b/app/views/sites/edit.haml
index 4ae7308d..2013c6f6 100644
--- a/app/views/sites/edit.haml
+++ b/app/views/sites/edit.haml
@@ -1,5 +1,5 @@
.row.justify-content-center
- .col-md-8
+ .col-12.col-lg-8
%h1= t('.title', site: @site.name)
= render 'form', site: @site, submit: t('.submit')
diff --git a/app/views/sites/fetch.haml b/app/views/sites/fetch.haml
index 6d670d6f..ab7a8f3b 100644
--- a/app/views/sites/fetch.haml
+++ b/app/views/sites/fetch.haml
@@ -1,5 +1,5 @@
.row.justify-content-center
- .col-md-8#pull
+ .col-12.col-lg-8#pull
%h1= t('.title')
%p.lead= sanitize_markdown t('.help.fetch'), tags: %w[em strong a]
@@ -10,7 +10,7 @@
- @commits.each do |commit|
.row.justify-content-center
- .col-md-8{ id: commit.oid }
+ .col-12.col-lg-8{ id: commit.oid }
%h1= commit.summary
%p.lead= render 'layouts/time', time: commit.time
@@ -25,6 +25,6 @@
- unless @commits.empty?
.row.justify-content-center
- .col-md-8
+ .col-12.col-lg-8
= link_to t('.merge.request'), site_pull_path(@site),
method: 'post', class: 'btn btn-secondary btn-lg'
diff --git a/app/views/sites/index.haml b/app/views/sites/index.haml
index 1befa2d0..3bc1477f 100644
--- a/app/views/sites/index.haml
+++ b/app/views/sites/index.haml
@@ -1,5 +1,5 @@
%main.row
- %aside.col-md-3
+ %aside.col-12.col-lg-3
%h1= t('.title')
%p.lead= t('.help')
- if policy(Site).new?
@@ -26,6 +26,7 @@
- else
= site.title
%p.lead= site.description
+ %br
.d-flex.flex-row
= link_to t('.visit'), site.url, class: 'btn btn-secondary'
- if current_usuarie.rol_for_site(site).temporal?
diff --git a/app/views/sites/new.haml b/app/views/sites/new.haml
index 68c17882..23ee8b40 100644
--- a/app/views/sites/new.haml
+++ b/app/views/sites/new.haml
@@ -1,5 +1,5 @@
.row.justify-content-center
- .col-md-8
+ .col-12.col-lg-8
%h1= t('.title')
%p.lead= t('.help')
diff --git a/app/views/stats/index.haml b/app/views/stats/index.haml
index 1c1a31f1..6832b7ab 100644
--- a/app/views/stats/index.haml
+++ b/app/views/stats/index.haml
@@ -38,7 +38,7 @@
- if @normalized_urls.present?
= line_chart site_stats_uris_path(urls: @normalized_urls, **@chart_params), **@chart_options
- .row.mb-5.row-cols-1.row-cols-md-2
+ .row.mb-5.row-cols-1.row-cols-lg-2
- @columns.each_pair do |column, values|
- next if values.blank?
.col.mb-5
diff --git a/app/views/targets/array/_item.haml b/app/views/targets/array/_item.haml
new file mode 100644
index 00000000..a4fa80ac
--- /dev/null
+++ b/app/views/targets/array/_item.haml
@@ -0,0 +1,24 @@
+-#
+ Un item de un array.
+
+ Además de los valores por defecto, se pueden pasar otros atributos
+ para el div del ítem.
+
+ @param :value [String] El valor (requerido)
+ @param :human-value [String] El valor legible por humanes (opcional)
+ @param :send-value [String] El valor que se envía al controlador (opcional)
+ @param :searchable-value [String] El valor para usar en el filtro (opcional)
+
+:ruby
+ local_assigns[:'human-value'] ||= value
+ local_assigns[:'send-value'] ||= local_assigns[:'human-value']
+ local_assigns[:'searchable-value'] ||= local_assigns[:'human-value'].remove_diacritics.downcase
+ local_assigns.delete(:value)
+
+ data = local_assigns.delete(:data)
+ data ||= {}
+ data[:'human-value'] = local_assigns.delete(:'human-value')
+ data[:'send-value'] = local_assigns.delete(:'send-value')
+ data[:'searchable-value'] = local_assigns.delete(:'searchable-value')
+
+%div{ **local_assigns, data: { 'array-target': 'item', value: value, **data } }= yield
diff --git a/app/views/usuaries/index.haml b/app/views/usuaries/index.haml
index f972a91f..265af56a 100644
--- a/app/views/usuaries/index.haml
+++ b/app/views/usuaries/index.haml
@@ -1,5 +1,5 @@
.row.justify-content-center
- .col.col-md-8
+ .col.col-lg-8
%h1= t('.title')
-# Una tabla de usuaries y otra de invitades, con acciones
diff --git a/app/views/usuaries/invite.haml b/app/views/usuaries/invite.haml
index 2698fb8f..0a9be9c8 100644
--- a/app/views/usuaries/invite.haml
+++ b/app/views/usuaries/invite.haml
@@ -1,7 +1,7 @@
- invite_as = t("usuaries.invite_as.#{params[:invite_as]}")
.row.justify-content-center
- .col.col-md-8
+ .col.col-lg-8
%h1= t('.title', invite_as: invite_as)
= form_with url: site_usuaries_invite_path(@site), local: true do |f|
diff --git a/config/application.rb b/config/application.rb
index ed7e5a78..8dbdcbdd 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -68,7 +68,6 @@ module Sutty
config.active_record.schema_format = :sql
config.to_prepare do
- # Load application's model / class decorators
Dir.glob(File.join(File.dirname(__FILE__), '..', 'app', '**', '*_decorator.rb')).sort.each do |c|
Rails.configuration.cache_classes ? require(c) : load(c)
end
@@ -87,7 +86,7 @@ module Sutty
end
def nodes
- @nodes ||= ENV.fetch('SUTTY_NODES', '').split(',')
+ @nodes ||= ENV.fetch('SUTTY_NODES', 'anarres.sutty.nl').split(',')
end
end
end
diff --git a/config/database.yml b/config/database.yml
index cd599a24..41fae09c 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -26,8 +26,4 @@ test:
user: <%= ENV['USER'] %>
production:
- adapter: postgresql
- pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
- database: <%= ENV.fetch('DATABASE') { 'sutty' } %>
- user: sutty
- host: postgresql
+ url: <%= ENV['DATABASE_URL'] %>
diff --git a/config/environments/test.rb b/config/environments/test.rb
index 05506587..bf72d234 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -50,6 +50,15 @@ Rails.application.configure do
config.action_mailer.default_url_options = { host: 'localhost',
port: 3000 }
+ config.middleware.use ExceptionNotification::Rack,
+ error_grouping: true,
+ email: {
+ email_prefix: '',
+ sender_address: ENV['DEFAULT_FROM'],
+ exception_recipients: ENV['EXCEPTION_TO'],
+ normalize_subject: true
+ }
+
# Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr
diff --git a/config/initializers/core_extensions.rb b/config/initializers/core_extensions.rb
index 7d1eab9e..024a26ab 100644
--- a/config/initializers/core_extensions.rb
+++ b/config/initializers/core_extensions.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
String.include CoreExtensions::String::StripTags
+String.include CoreExtensions::String::RemoveDiacritics
Jekyll::Document.include CoreExtensions::Jekyll::Document::Path
Jekyll::DataReader.include Jekyll::Readers::DataReaderDecorator
@@ -125,7 +126,8 @@ module Jekyll
unless spec
I18n.with_locale(locale) do
- raise Jekyll::Errors::InvalidThemeName, I18n.t('activerecord.errors.models.site.attributes.design_id.missing_gem', theme: name)
+ raise Jekyll::Errors::InvalidThemeName,
+ I18n.t('activerecord.errors.models.site.attributes.design_id.missing_gem', theme: name)
rescue Jekyll::Errors::InvalidThemeName => e
ExceptionNotifier.notify_exception(e, data: { theme: name, site: File.basename(site.source) })
raise
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 977d99e3..295768f8 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -120,6 +120,13 @@ en:
confirm_report: "Send report to the remote instance? This action will also block the account."
remote_flags:
report_message: "Hi! Someone using Sutty CMS reported this account on your instance. We don't have support for customized report messages yet, but we will soon. You can reach us at %{panel_actor_mention}."
+ activity_pub:
+ actor_flagged_mailer:
+ notify_moderators:
+ subject: "A site has been reported"
+ content: "The site with id %{site_id} has been reported. Please review the report ASAP."
+ actor: "Site"
+ uris: "Reported activities"
activity_pubs:
action_on_several:
success: "Several comments have changed moderation state. You can find them using the filters on the Comments section."
@@ -334,10 +341,6 @@ en:
title: Synchronize to another Sutty node
success: Success!
error: Error
- deploy_distributed_press:
- title: Distributed Web
- success: Success!
- error: Error
help: You can contact us by replying to this e-mail
maintenance_mailer:
notice:
@@ -401,6 +404,8 @@ en:
not_available: "This language is not yet available, would you help us by translating Sutty into it?"
errors:
site_not_found: "Site not found, or maybe you don't have access to it."
+ page_not_found: "Page not found."
+ page_unauthorized: "You don't have access to this page, please contact the operators of this site."
argument_error: 'Argument `%{argument}` must be an instance of %{class}'
unknown_locale: 'Unknown %{locale} locale'
posts:
@@ -515,7 +520,7 @@ en:
Also, your site will be featured on the [Distributed
Press directory](https://explore.distributed.press/).
- [Learn more](https://sutty.nl/learn-more-about-publish-to-dweb-functionality/)
+ [Learn more](https://sutty.nl/en/learn-more-about-publish-to-dweb-functionality/)
deploy_social_distributed_press:
title: 'Publish on the Fediverse'
help: |
@@ -638,8 +643,8 @@ en:
help: Please, look for the invalid fields to fix them
help:
name: "This will be the host name for your site, ie. **example**.sutty.nl. Choose an expression up to 63 characters. It can contain only lowercase letters, numbers and dashes, **and no spaces**. It can't start or end with a dash, or be entirely composed of numbers."
- title: 'The title can be anything you want'
- description: 'You site description that appears in search engines. Between 50 and 160 characters.'
+ title: 'The title can be anything you want.'
+ description: 'You site description that appears in search engines. Between 10 and 160 characters.'
design: 'Select the design for your site. We add more designs from time to time!'
licencia: 'Everything we publish has automatic copyright. This
means nobody can use our works without explicit permission. By
@@ -726,10 +731,13 @@ en:
submit:
save: 'Save'
save_draft: 'Save as draft'
- invalid_help: 'Some fields need attention! Please search for the fields marked as invalid.'
- sending_help: Saving, please wait...
+ validation:
+ invalid: "Some fields need attention! Please search for the fields marked as not valid."
+ submitting: "Saving changes, please wait..."
attributes:
add: Add
+ title:
+ label: "Title"
lang:
label: Language
date:
@@ -753,12 +761,36 @@ en:
image:
label: Image
destroy: Remove image
+ audio:
+ label: Audio file
+ logo:
+ label: Logo
+ download:
+ label: Archivo
belongs_to:
empty: "(Empty)"
predefined_value:
empty: "(Empty)"
draft:
label: Draft
+ new_has_many:
+ edit: "Edit"
+ disabled: "It's already associated to an article."
+ new_has_and_belongs_to_many:
+ edit: "Edit"
+ new_predefined_array:
+ edit: "Edit"
+ new_predefined_value:
+ empty: "(Empty)"
+ new_array:
+ edit: "Edit"
+ new_has_one:
+ edit: "Edit"
+ new_belongs_to:
+ edit: "Edit"
+ disabled: "It's already associated to an article."
+ required_checkbox:
+ required: "Please select at least one option."
reorder:
submit: 'Save order'
select: 'Select this post'
@@ -805,6 +837,7 @@ en:
destroy: Delete
confirm_destroy: Are you sure?
form:
+ confirm: "You have unsaved changes and changing pages may lose them, continue anyway?"
errors:
title: There are some errors on the form
help: Please, verify that all values are correct.
@@ -930,14 +963,32 @@ en:
queries:
show:
empty: '(empty)'
+ build_stats:
+ index:
+ title: "Publications"
schemas:
add:
add: 'Add'
filter:
filter: 'Filter'
remove: 'Back'
- build_stats:
- index:
- title: "Publications"
indexed_posts:
deleted: "Deleted indexed post %{path} from %{site} (records: %{records})"
+ bootstrap:
+ modal:
+ accept: "Accept"
+ add: "Add %{layout}"
+ add_new: "Add new option"
+ cancel: "Cancel"
+ close: "Close without saving"
+ edit: "Edit"
+ filter: "Start typing to filter..."
+ save: "Save"
+ card:
+ edit: "Edit"
+ alert:
+ saved: "Changes were saved!"
+ targets:
+ array:
+ item:
+ remove: "Remove"
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 0a54d3ab..cac5b111 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -119,6 +119,13 @@ es:
confirm_report: "¿Enviar el reporte a la instancia remota? Esta acción también bloqueará la cuenta."
remote_flags:
report_message: "¡Hola! Une usuarie de Sutty CMS reportó esta cuenta en tu instancia. Todavía no tenemos soporte para mensajes personalizados. Podés contactarnos en %{panel_actor_mention}."
+ activity_pub:
+ actor_flagged_mailer:
+ notify_moderators:
+ subject: "Un sitio ha sido reportado"
+ content: "El sitio con id %{site_id} ha sido reportado. Por favor revisa el reporte a la brevedad."
+ actor: "Sitio"
+ uris: "Actividades reportadas"
activity_pubs:
action_on_several:
success: "Se ha modificado el estado de moderación de varios comentarios. Podés encontrarlos usando los filtros en la sección Comentarios."
@@ -317,10 +324,6 @@ es:
title: Fediverso
success: ¡Éxito!
error: Hubo un error
- deploy_reindex:
- title: Reindexación
- success: ¡Éxito!
- error: Hubo un error
deploy_localized_domain:
title: Dominio según idioma
success: ¡Éxito!
@@ -329,12 +332,12 @@ es:
title: Sincronizar al servidor alternativo
success: ¡Éxito!
error: Hubo un error
- deploy_full_rsync:
- title: Sincronizar a otro nodo de Sutty
+ deploy_reindex:
+ title: Reindexación
success: ¡Éxito!
error: Hubo un error
- deploy_distributed_press:
- title: Web distribuida
+ deploy_full_rsync:
+ title: Sincronizar a otro nodo de Sutty
success: ¡Éxito!
error: Hubo un error
help: Por cualquier duda, responde este correo para contactarte con nosotres.
@@ -400,6 +403,8 @@ es:
not_available: "Este idioma todavía no está disponible, ¿nos ayudas a agregarlo y mantenerlo?"
errors:
site_not_found: "No encontramos ese sitio o quizás no tengas acceso."
+ page_not_found: "No encontramos esa página."
+ page_unauthorized: "No tenés acceso a página, para solicitarla, ponete en contacto con les gestores del sitio."
argument_error: 'El argumento `%{argument}` debe ser una instancia de %{class}'
unknown_locale: 'El idioma %{locale} es desconocido'
posts:
@@ -644,7 +649,7 @@ es:
help:
name: 'El nombre de tu sitio que formará parte de la dirección (**ejemplo**.sutty.nl). Solo puede contener hasta 63 letras minúsculas, números y guiones, pero **sin espacios**. No puede empezar ni terminar con guión, ni estar compuesto enteramente por números.'
title: 'El título de tu sitio puede ser lo que quieras.'
- description: 'La descripción del sitio, que saldrá en buscadores. Entre 50 y 160 caracteres.'
+ description: 'La descripción del sitio, que saldrá en buscadores. Entre 10 y 160 caracteres.'
design: 'Elegí el diseño que va a tener tu sitio aquí. De tanto en tanto vamos sumando diseños nuevos.'
licencia: 'Todo lo que publicamos posee automáticamente derechos
de autore. Esto significa que nadie puede hacer uso de nuestras
@@ -734,10 +739,13 @@ es:
submit:
save: 'Guardar'
save_draft: 'Guardar como borrador'
- invalid_help: '¡Te faltan completar algunos campos! Busca los que estén marcados como inválidos'
- sending_help: Guardando, por favor espera...
+ validation:
+ invalid: "¡Te falta completar algunos campos! Busca los que estén marcados como no válidos."
+ submitting: "Guardando, por favor espera..."
attributes:
add: Agregar
+ title:
+ label: "Título"
lang:
label: Idioma
date:
@@ -761,12 +769,38 @@ es:
image:
label: Imagen
destroy: 'Eliminar imagen'
+ logo:
+ label: Logo
+ audio:
+ label: Audio
+ download:
+ label: Archivo
belongs_to:
empty: "(Vacío)"
predefined_value:
empty: "(Vacío)"
draft:
label: Borrador
+ new_has_many:
+ edit: "Editar"
+ disabled: "Ya está relacionado con otro artículo."
+ new_has_and_belongs_to_many:
+ edit: "Editar"
+ new_predefined_array:
+ empty: "(Vacío)"
+ edit: "Editar"
+ new_predefined_value:
+ empty: "(Vacío)"
+ edit: "Editar"
+ new_array:
+ edit: "Editar"
+ new_has_one:
+ edit: "Editar"
+ new_belongs_to:
+ edit: "Editar"
+ disabled: "Ya está relacionado con otro artículo."
+ required_checkbox:
+ required: "Seleccioná al menos una opción."
reorder:
submit: 'Guardar orden'
select: 'Seleccionar este artículo'
@@ -813,6 +847,7 @@ es:
destroy: Borrar
confirm_destroy: ¿Estás segure?
form:
+ confirm: "Tenés cambios sin guardar y cambiar de página podría perderlos, ¿querés continuar de todas formas?"
errors:
title: Hay errores en el formulario
help: Por favor, verifica que todos los valores sean correctos.
@@ -938,14 +973,32 @@ es:
queries:
show:
empty: '(vacío)'
+ build_stats:
+ index:
+ title: "Publicaciones"
schemas:
add:
add: 'Agregar'
filter:
filter: 'Filtrar'
remove: 'Volver'
- build_stats:
- index:
- title: "Publicaciones"
indexed_posts:
deleted: "Eliminado artículo %{path} de %{site} (filas: %{records})"
+ bootstrap:
+ modal:
+ accept: "Aceptar"
+ add: "Agregar %{layout}"
+ add_new: "Agregar nueva opción"
+ cancel: "Cancelar"
+ close: "Cerrar sin guardar"
+ edit: "Editar"
+ filter: "Empezá a escribir para filtrar..."
+ save: "Guardar"
+ card:
+ edit: "Editar"
+ alert:
+ saved: "¡Cambios guardados!"
+ targets:
+ array:
+ item:
+ remove: "Eliminar"
diff --git a/config/routes.rb b/config/routes.rb
index 3dfc4c85..7b099137 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -6,7 +6,7 @@
SITE_ID_RE = %r{[^/]+}.freeze
Rails.application.routes.draw do
- devise_for :usuaries
+ devise_for :usuaries, controllers: { registrations: 'registrations' }
get '/.well-known/change-password', to: redirect('/usuaries/edit')
require 'que/web'
@@ -104,6 +104,14 @@ Rails.application.routes.draw do
nested do
scope '/(:locale)', constraint: /[a-z]{2}(-[A-Z]{2})?/ do
post :'posts/reorder', to: 'posts#reorder'
+
+ get :'posts/new_array', to: 'posts#new_array'
+ get :'posts/new_array_value', to: 'posts#new_array_value'
+ get :'posts/new_related_post', to: 'posts#new_related_post'
+ get :'posts/new_has_one', to: 'posts#new_has_one'
+ get :'posts/form', to: 'posts#form'
+ get :'posts/modal', to: 'posts#modal'
+
resources :posts do
get 'p/:page', action: :index, on: :collection
get :preview, to: 'posts#preview'
diff --git a/db/migrate/20230822165038_add_autopublish_to_sites.rb b/db/migrate/20230822165038_add_autopublish_to_sites.rb
new file mode 100644
index 00000000..2f89e9d2
--- /dev/null
+++ b/db/migrate/20230822165038_add_autopublish_to_sites.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+# Agrega posibilidad de autopublicación del sitio
+class AddAutopublishToSites < ActiveRecord::Migration[6.1]
+ def change
+ add_column :sites, :auto_publish, :boolean, default: false
+ end
+end
diff --git a/db/migrate/20240614191548_deploy_rol_id_can_be_null.rb b/db/migrate/20240614191548_deploy_rol_id_can_be_null.rb
new file mode 100644
index 00000000..425861aa
--- /dev/null
+++ b/db/migrate/20240614191548_deploy_rol_id_can_be_null.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+# El rol_id no es necesario para todos los deploys
+class DeployRolIdCanBeNull < ActiveRecord::Migration[6.1]
+ def change
+ change_column :deploys, :rol_id, :integer, null: true
+ end
+end
diff --git a/db/migrate/20241113183226_add_moderator_to_usuaries.rb b/db/migrate/20241113183226_add_moderator_to_usuaries.rb
new file mode 100644
index 00000000..073e3c70
--- /dev/null
+++ b/db/migrate/20241113183226_add_moderator_to_usuaries.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+# Agrega el campo moderator para saber quiénes son moderadores
+class AddModeratorToUsuaries < ActiveRecord::Migration[6.1]
+ def change
+ add_column :usuaries, :moderator, :boolean, default: false
+ end
+end
diff --git a/db/migrate/20241223185830_change_deploys_values_type_to_json_b.rb b/db/migrate/20241223185830_change_deploys_values_type_to_json_b.rb
new file mode 100644
index 00000000..5824a899
--- /dev/null
+++ b/db/migrate/20241223185830_change_deploys_values_type_to_json_b.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class ChangeDeploysValuesTypeToJsonB < ActiveRecord::Migration[6.1]
+ def up
+ add_column :deploys, :values_2, :jsonb, default: {}
+
+ Deploy.find_each do |deploy|
+ deploy.update values_2: JSON.parse(deploy.values)
+ end
+
+ remove_column :deploys, :values
+ rename_column :deploys, :values_2, :values
+ end
+
+ def down
+ add_column :deploys, :values_2, :text
+
+ Deploy.find_each do |deploy|
+ deploy.update values_2: deploy.values.to_json
+ end
+
+ remove_column :deploys, :values
+ rename_column :deploys, :values_2, :values
+ end
+end
diff --git a/db/structure.sql b/db/structure.sql
index 21cf04d0..3188b2fe 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -9,6 +9,13 @@ SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
+--
+-- Name: public; Type: SCHEMA; Schema: -; Owner: -
+--
+
+-- *not* creating schema, since initdb creates it
+
+
--
-- Name: pg_trgm; Type: EXTENSION; Schema: -; Owner: -
--
@@ -882,8 +889,8 @@ CREATE TABLE public.deploys (
updated_at timestamp without time zone NOT NULL,
site_id integer,
type character varying,
- "values" text,
- rol_id integer
+ rol_id integer,
+ "values" jsonb DEFAULT '{}'::jsonb
);
@@ -1490,7 +1497,8 @@ CREATE TABLE public.usuaries (
privacy_policy_accepted_at timestamp without time zone,
terms_of_service_accepted_at timestamp without time zone,
code_of_conduct_accepted_at timestamp without time zone,
- available_for_feedback_accepted_at timestamp without time zone
+ available_for_feedback_accepted_at timestamp without time zone,
+ moderator boolean DEFAULT false
);
@@ -2600,6 +2608,13 @@ ALTER TABLE ONLY public.active_storage_attachments
ADD CONSTRAINT fk_rails_c3b3935057 FOREIGN KEY (blob_id) REFERENCES public.active_storage_blobs(id);
+--
+-- Name: publisher; Type: PUBLICATION; Schema: -; Owner: -
+--
+
+CREATE PUBLICATION publisher FOR ALL TABLES WITH (publish = 'insert, update, delete, truncate');
+
+
--
-- PostgreSQL database dump complete
--
@@ -2719,6 +2734,9 @@ INSERT INTO "schema_migrations" (version) VALUES
('20240316203721'),
('20240318183846'),
('20240319124212'),
-('20240319144735');
+('20240319144735'),
+('20240614191548'),
+('20241113183226'),
+('20241223185830');
diff --git a/monit.conf b/monit.conf
index 7ad96852..25b8624e 100644
--- a/monit.conf
+++ b/monit.conf
@@ -15,7 +15,7 @@ check program fediblocks
if status != 0 then alert
check program access_logs
- with path "/srv/http/bin/access_logs" as uid "app" and gid "www-data"
+ with path "/srv/bin/access_logs" as uid "app" and gid "www-data"
every "0 3 * * *"
if status != 0 then alert
diff --git a/package.json b/package.json
index 088316bc..cd2ddadf 100644
--- a/package.json
+++ b/package.json
@@ -9,11 +9,14 @@
"@babel/plugin-transform-runtime": "^7.12.17",
"@babel/preset-env": "^7.12.17",
"@babel/preset-typescript": "~7.12",
+ "@hotwired/stimulus": "^3.2.2",
+ "@hotwired/stimulus-webpack-helpers": "^1.0.1",
"@rails/actiontext": "^6.0.0",
"@rails/activestorage": "^6.1.3-1",
"@rails/ujs": "^6.1.3-1",
"@rails/webpacker": "5.4.4",
"@suttyweb/editor": "^0.1.29",
+ "@suttyweb/htmx.org": "2.0.0",
"babel-loader": "^8.2.2",
"bs-custom-file-input": "^1.3.4",
"chart.js": "^3.5.1",
@@ -22,7 +25,6 @@
"commonmark": "^0.29.0",
"fork-awesome": "^1.1.7",
"fork-ts-checker-webpack-plugin": "^6.1.0",
- "htmx.org": "^1.9.11",
"input-map": "git+https://0xacab.org/sutty/input-map.git",
"input-tag": "git+https://0xacab.org/sutty/input-tag.git",
"leaflet": "^1.7.1",
diff --git a/public/.well-known/hall-of-fame.txt b/public/.well-known/hall-of-fame.txt
new file mode 100644
index 00000000..6a40abe2
--- /dev/null
+++ b/public/.well-known/hall-of-fame.txt
@@ -0,0 +1,22 @@
+-----BEGIN PGP SIGNED MESSAGE-----
+Hash: SHA512
+
+# === Hall of Fame ===
+#
+# This is a list of very awesome and friendly hackers who have reported
+# security issues in git-shortlog format.
+
+Parth Narula (3):
+ Hyperlink Injection https://0xacab.org/sutty/sutty/-/issues/17494
+ Email Flooding Vulnerability https://0xacab.org/sutty/sutty/-/issues/17493
+ Missing MTA-STS https://0xacab.org/sutty/sutty.nl/-/commit/e506a3f3fedb46979894f4d9dab665723d855a50
+
+Sakil Hasan Saikat (1):
+ Exposed yarn.lock File Leading to Potential Information Disclosure https://0xacab.org/sutty/sutty/-/issues/18071
+-----BEGIN PGP SIGNATURE-----
+
+iHUEARYKAB0WIQRb/QhO+qrWre3YhiVzWVgylXkBZQUCZ2mhQAAKCRBzWVgylXkB
+ZQSAAP449kcjD8wD97UifD98xwXxxiOINwuu7congn4haEuFIgEA8Xz+qLBHU2g2
+ybXZP+lER0kV2dVexCDrbWbVT8kPJA4=
+=PEVU
+-----END PGP SIGNATURE-----
diff --git a/public/.well-known/pgp.asc b/public/.well-known/pgp.asc
index 83717c46..da7ba6fc 100644
--- a/public/.well-known/pgp.asc
+++ b/public/.well-known/pgp.asc
@@ -1,13 +1,13 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
-mDMEXuIuxxYJKwYBBAHaRw8BAQdAx7++TG7xSYPtEC7cALkX2bQkIsPdiPjA1NW6
-KyZIXjS0GFN1dHR5IDxzdXR0eUByaXNldXAubmV0PoiQBBMWCgA4FiEEODcdZeeQ
-ThO24WEhhg0wFh4HfXEFAl7iLscCGwMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AA
-CgkQhg0wFh4HfXHewQEA7PIVXSrXapCqz+bBypFHeowtiqi8PCJeaueeDWN7+1AB
-AKerQ/C56DiSpwCdNDvlleuRlhk3TedStnZOZw83T4UDuDgEXuIuxxIKKwYBBAGX
-VQEFAQEHQGl8Q/uPz3VwWPpAS6KJLZI27caqsgG416mSrbU54YQ1AwEIB4h4BBgW
-CgAgFiEEODcdZeeQThO24WEhhg0wFh4HfXEFAl7iLscCGwwACgkQhg0wFh4HfXHM
-CAEA5Lw718/jYN1DztG8/mGI3E7le19NSjdkc00p8VBESpcBAL4bNmVKqPZa14/D
-eu2uHSY1XcLpdUjD+eq0KjGpG90M
-=X71f
+mDMEZ2mRdBYJKwYBBAHaRw8BAQdAv+efdxjE3mScSj9gE/aToTRM1a7BjhGJ3ZOF
+frWMnYW0HVN1dHR5IENvb3AgPHN1dHR5QHJpc2V1cC5uZXQ+iJkEExYKAEEWIQRb
+/QhO+qrWre3YhiVzWVgylXkBZQUCZ2mRdAIbAwUJBaOagAULCQgHAgIiAgYVCgkI
+CwIEFgIDAQIeBwIXgAAKCRBzWVgylXkBZSRAAQD3l2jbDGPjXyDo2nfZ+/cBuy77
+dTFK4wzifDmeCr8MfwEAs1Qvh/4bHcPyjL8E07UZQfdA0BA9hdzDLSQoYRe2ZAm4
+OARnaZF0EgorBgEEAZdVAQUBAQdAiW4wq8MhDMM8Tw8JTOyuYUT7QCH5he4Fi37F
+9+upXg0DAQgHiH4EGBYKACYWIQRb/QhO+qrWre3YhiVzWVgylXkBZQUCZ2mRdAIb
+DAUJBaOagAAKCRBzWVgylXkBZSvDAP4kPEH+llMvjkAN68+ezBqrRwxbSzjlVziR
+wB29o4OELwD/fZZfDan6PSiigXRwH0vImXSTaXCO0nk8sSfeQfhcpgY=
+=njjL
-----END PGP PUBLIC KEY BLOCK-----
diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt
index 1783385f..c0cf2200 100644
--- a/public/.well-known/security.txt
+++ b/public/.well-known/security.txt
@@ -5,12 +5,11 @@ Contact: sutty+security@riseup.net
Encryption: https://panel.sutty.nl/.well-known/pgp.asc
Preferred-Languages: es,en
Canonical: https://panel.sutty.nl/.well-known/security.txt
+Acknowledgments: https://panel.sutty.nl/.well-known/hall-of-fame.txt
-----BEGIN PGP SIGNATURE-----
-iNUEARYKAH0WIQQ4Nx1l55BOE7bhYSGGDTAWHgd9cQUCX7WQZV8UgAAAAAAuAChp
-c3N1ZXItZnByQG5vdGF0aW9ucy5vcGVucGdwLmZpZnRoaG9yc2VtYW4ubmV0Mzgz
-NzFENjVFNzkwNEUxM0I2RTE2MTIxODYwRDMwMTYxRTA3N0Q3MQAKCRCGDTAWHgd9
-cTBjAP9CxBiGyhkGdtcv1uUUZEG2Oq3RdYjr6fGbVDQt7YidBQD/U4pyDz+dwkZZ
-0+YAA9Hst0RqOwJpLh5yPGCVIdhGLgE=
-=CxQY
+iHUEARYKAB0WIQRb/QhO+qrWre3YhiVzWVgylXkBZQUCZ2mTkQAKCRBzWVgylXkB
+ZYI7AP9rROT5tInVlfjt1sTIYpEqO7H6IVWt6gBC2YkcaS5mvgEA1tIi9FZ2vT4F
+WTPg+c5FxXku+uggUQCYPhTeG8RWJwE=
+=4vAv
-----END PGP SIGNATURE-----
diff --git a/public/assets/.sprockets-manifest-a1cbb907961024fc033716a7d30668dd.json b/public/assets/.sprockets-manifest-a1cbb907961024fc033716a7d30668dd.json
new file mode 100644
index 00000000..3a8e71cf
--- /dev/null
+++ b/public/assets/.sprockets-manifest-a1cbb907961024fc033716a7d30668dd.json
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:88a525495e7ee1849a753cea23439fd7aa557dd1c0fd72764deb5cc2475d0e4f
+size 11449
diff --git a/public/assets/.sprockets-manifest-c6294bb290dcb7473076f4de99ce9c00.json b/public/assets/.sprockets-manifest-c6294bb290dcb7473076f4de99ce9c00.json
deleted file mode 100644
index ecd1aee3..00000000
--- a/public/assets/.sprockets-manifest-c6294bb290dcb7473076f4de99ce9c00.json
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:53b13d54381374696503351fd6661242b1e22ea6f2078678bc560dfcfb701c8a
-size 10242
diff --git a/public/assets/application-1672a7ce51150843fc752c408e42d93aa4efd07ee5ae35e1fa3c2807ae621573.css b/public/assets/application-1672a7ce51150843fc752c408e42d93aa4efd07ee5ae35e1fa3c2807ae621573.css
new file mode 100644
index 00000000..a3d962c2
--- /dev/null
+++ b/public/assets/application-1672a7ce51150843fc752c408e42d93aa4efd07ee5ae35e1fa3c2807ae621573.css
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:898f2857532d309738de5f2a67e282587a5c9a73bfecedceecc53ca187619549
+size 245300
diff --git a/public/assets/application-1672a7ce51150843fc752c408e42d93aa4efd07ee5ae35e1fa3c2807ae621573.css.gz b/public/assets/application-1672a7ce51150843fc752c408e42d93aa4efd07ee5ae35e1fa3c2807ae621573.css.gz
new file mode 100644
index 00000000..9378ed5a
--- /dev/null
+++ b/public/assets/application-1672a7ce51150843fc752c408e42d93aa4efd07ee5ae35e1fa3c2807ae621573.css.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:52c8e38acd7843eb3d1f89559894c7e3dc76d7edfd139966df8717392bbb4275
+size 34401
diff --git a/public/assets/application-20ccc989c289957de41530f1a6c63720c6980e71b85b2b73698ee2ad85e39788.css b/public/assets/application-20ccc989c289957de41530f1a6c63720c6980e71b85b2b73698ee2ad85e39788.css
new file mode 100644
index 00000000..109c05a1
--- /dev/null
+++ b/public/assets/application-20ccc989c289957de41530f1a6c63720c6980e71b85b2b73698ee2ad85e39788.css
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4c7d185f6b9de802352ba5905ed3becdf8248e231484006e3ed3efabbd15e9b6
+size 236932
diff --git a/public/assets/application-20ccc989c289957de41530f1a6c63720c6980e71b85b2b73698ee2ad85e39788.css.gz b/public/assets/application-20ccc989c289957de41530f1a6c63720c6980e71b85b2b73698ee2ad85e39788.css.gz
new file mode 100644
index 00000000..e1f69469
--- /dev/null
+++ b/public/assets/application-20ccc989c289957de41530f1a6c63720c6980e71b85b2b73698ee2ad85e39788.css.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d85bfc90e555d5a4d45424fe6b7c0b55baa560d4eb1db9ec0fe678d8dc126f7d
+size 32753
diff --git a/public/assets/application-5c98ae5b7e5b4444349f4c0175aa88fb76292284e1c68bfc4724151ceaf5113a.css b/public/assets/application-5c98ae5b7e5b4444349f4c0175aa88fb76292284e1c68bfc4724151ceaf5113a.css
new file mode 100644
index 00000000..1945b981
--- /dev/null
+++ b/public/assets/application-5c98ae5b7e5b4444349f4c0175aa88fb76292284e1c68bfc4724151ceaf5113a.css
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:84981ad08b14786d825a89f6de709c8951b403f7dd5f5fa88f6a77e16760f284
+size 237180
diff --git a/public/assets/application-5c98ae5b7e5b4444349f4c0175aa88fb76292284e1c68bfc4724151ceaf5113a.css.gz b/public/assets/application-5c98ae5b7e5b4444349f4c0175aa88fb76292284e1c68bfc4724151ceaf5113a.css.gz
new file mode 100644
index 00000000..01e72c45
--- /dev/null
+++ b/public/assets/application-5c98ae5b7e5b4444349f4c0175aa88fb76292284e1c68bfc4724151ceaf5113a.css.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3df707f1055c66159e723d71cbfa6d501df539538a27e9fc63e789ff7df11e39
+size 32845
diff --git a/public/assets/application-ab39e7743243e474eabb5a95f8ff3c009c2ac8275313e1aa472ddfd0c419380b.css b/public/assets/application-ab39e7743243e474eabb5a95f8ff3c009c2ac8275313e1aa472ddfd0c419380b.css
new file mode 100644
index 00000000..7e9caa1b
--- /dev/null
+++ b/public/assets/application-ab39e7743243e474eabb5a95f8ff3c009c2ac8275313e1aa472ddfd0c419380b.css
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:196896e7c9c709c8864d2e2241cb87ab9ada0f6f6c37a5bb35042c72d2bb0876
+size 245181
diff --git a/public/assets/application-ab39e7743243e474eabb5a95f8ff3c009c2ac8275313e1aa472ddfd0c419380b.css.gz b/public/assets/application-ab39e7743243e474eabb5a95f8ff3c009c2ac8275313e1aa472ddfd0c419380b.css.gz
new file mode 100644
index 00000000..74f83174
--- /dev/null
+++ b/public/assets/application-ab39e7743243e474eabb5a95f8ff3c009c2ac8275313e1aa472ddfd0c419380b.css.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:516b70b51da7677a149123b3c7b57794af0b962ba9a81e4b54f7cb010611d0bc
+size 34375
diff --git a/public/assets/application-d15aa752c8ae9368a7974551a07ceb36c9b7fdbc288f733d270fefdd9d885318.css b/public/assets/application-d15aa752c8ae9368a7974551a07ceb36c9b7fdbc288f733d270fefdd9d885318.css
new file mode 100644
index 00000000..3eaa5e30
--- /dev/null
+++ b/public/assets/application-d15aa752c8ae9368a7974551a07ceb36c9b7fdbc288f733d270fefdd9d885318.css
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:246bffb3855dbcb25f3a938738dce9d4870b0c519f0606e2bcd02d1d18d1f8d6
+size 245687
diff --git a/public/assets/application-d15aa752c8ae9368a7974551a07ceb36c9b7fdbc288f733d270fefdd9d885318.css.gz b/public/assets/application-d15aa752c8ae9368a7974551a07ceb36c9b7fdbc288f733d270fefdd9d885318.css.gz
new file mode 100644
index 00000000..ab04f333
--- /dev/null
+++ b/public/assets/application-d15aa752c8ae9368a7974551a07ceb36c9b7fdbc288f733d270fefdd9d885318.css.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:dccb9deaa7d39347f7ce40d98b1f6eb19def1c96ad00c09cba8bdf757401ac45
+size 34501
diff --git a/public/assets/arrows-alt-v-89a34626be855d3b1c3199cd75f62cf6327678eed1e126f74b7cbc6de3502606.svg.gz b/public/assets/arrows-alt-v-89a34626be855d3b1c3199cd75f62cf6327678eed1e126f74b7cbc6de3502606.svg.gz
index f5afff36..5b4b25ab 100644
--- a/public/assets/arrows-alt-v-89a34626be855d3b1c3199cd75f62cf6327678eed1e126f74b7cbc6de3502606.svg.gz
+++ b/public/assets/arrows-alt-v-89a34626be855d3b1c3199cd75f62cf6327678eed1e126f74b7cbc6de3502606.svg.gz
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:f690800fb0f0e2ce7b86b2564f2b4521030854c3eb13ccad730911181b906a1e
+oid sha256:a9c026060870ad426fdd5f04de97ab73f9e8d3fbcdb5fc389652c2dbb8d58cac
size 359
diff --git a/_sites/.keep b/public/assets/blazer/application-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js
similarity index 100%
rename from _sites/.keep
rename to public/assets/blazer/application-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js
diff --git a/public/assets/blazer/application-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js.gz b/public/assets/blazer/application-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js.gz
new file mode 100644
index 00000000..3ac4f898
--- /dev/null
+++ b/public/assets/blazer/application-04024382391bb910584145d8113cf35ef376b55d125bb4516cebeb14ce788597.js.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0a808f31c5704c32a1b85155b41b68f60e08929ab31352cd811138a2c70cacf3
+size 20
diff --git a/public/assets/blazer/application-c68598fc5732d248b857f9712a627ca58483ad188d6764320941b74fa71bbcac.css b/public/assets/blazer/application-c68598fc5732d248b857f9712a627ca58483ad188d6764320941b74fa71bbcac.css
new file mode 100644
index 00000000..3070f7a1
--- /dev/null
+++ b/public/assets/blazer/application-c68598fc5732d248b857f9712a627ca58483ad188d6764320941b74fa71bbcac.css
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c1c391e4691ea1afd08ba5daff66c790edd4b324af7dae390b616af36c7c2837
+size 134418
diff --git a/public/assets/blazer/application-c68598fc5732d248b857f9712a627ca58483ad188d6764320941b74fa71bbcac.css.gz b/public/assets/blazer/application-c68598fc5732d248b857f9712a627ca58483ad188d6764320941b74fa71bbcac.css.gz
new file mode 100644
index 00000000..f73b6993
--- /dev/null
+++ b/public/assets/blazer/application-c68598fc5732d248b857f9712a627ca58483ad188d6764320941b74fa71bbcac.css.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:858413c8cd07dff8ee14d3d8a2dbd98c6bad6a6dfd5b202a0e3bb00972dc9ef3
+size 23333
diff --git a/public/assets/blazer/glyphicons-halflings-regular-0805fb1fe24235f70a639f67514990e4bfb6d2cfb00ca563ad4b553c240ddc33.eot.gz b/public/assets/blazer/glyphicons-halflings-regular-0805fb1fe24235f70a639f67514990e4bfb6d2cfb00ca563ad4b553c240ddc33.eot.gz
index 44d30965..79704b9c 100644
--- a/public/assets/blazer/glyphicons-halflings-regular-0805fb1fe24235f70a639f67514990e4bfb6d2cfb00ca563ad4b553c240ddc33.eot.gz
+++ b/public/assets/blazer/glyphicons-halflings-regular-0805fb1fe24235f70a639f67514990e4bfb6d2cfb00ca563ad4b553c240ddc33.eot.gz
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:232dfba09dd29cfeff9e075a9ba30ed192f678b5946c1a1ec022e9887cc1dc8f
+oid sha256:ec5793d14d9c94d2e77f2402cc72cb02fbad30c2f9e694db24a1770c68b85ef2
size 20056
diff --git a/public/assets/blazer/glyphicons-halflings-regular-22d0c88a49d7d0ebe45627143a601061a32a46a9b9afd2dc7f457436f5f15f6e.svg.gz b/public/assets/blazer/glyphicons-halflings-regular-22d0c88a49d7d0ebe45627143a601061a32a46a9b9afd2dc7f457436f5f15f6e.svg.gz
index 49a5ccf2..ab024c17 100644
--- a/public/assets/blazer/glyphicons-halflings-regular-22d0c88a49d7d0ebe45627143a601061a32a46a9b9afd2dc7f457436f5f15f6e.svg.gz
+++ b/public/assets/blazer/glyphicons-halflings-regular-22d0c88a49d7d0ebe45627143a601061a32a46a9b9afd2dc7f457436f5f15f6e.svg.gz
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:2879447637c85f780c62c9decfdc9b6847dfddc0c353ecf056a5563afd5d98c0
+oid sha256:3c0063a75b931092fa7dc19eab7df95f9b715c4444a164f3d63a37c3a7ac4b8f
size 26508
diff --git a/public/assets/blazer/glyphicons-halflings-regular-7c9caa5f4e16169b0129fdf93c84e85ad14d6c107eb1b0ad60b542daf01ee1f0.ttf.gz b/public/assets/blazer/glyphicons-halflings-regular-7c9caa5f4e16169b0129fdf93c84e85ad14d6c107eb1b0ad60b542daf01ee1f0.ttf.gz
index e77b3e37..5c760dc2 100644
--- a/public/assets/blazer/glyphicons-halflings-regular-7c9caa5f4e16169b0129fdf93c84e85ad14d6c107eb1b0ad60b542daf01ee1f0.ttf.gz
+++ b/public/assets/blazer/glyphicons-halflings-regular-7c9caa5f4e16169b0129fdf93c84e85ad14d6c107eb1b0ad60b542daf01ee1f0.ttf.gz
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c0668c774700aef315f25dcf8376be7aa88eec8b609b47f37c9ba53bbdb997ee
+oid sha256:54d10fbf4ee64ed8263cac863485e034c3f430afd686301c51cde3ecd49612b9
size 23360
diff --git a/public/assets/dark-591813c5ed25b766eda449e80715f9e51238c5defc9e38e60f02c31e11207839.css b/public/assets/dark-591813c5ed25b766eda449e80715f9e51238c5defc9e38e60f02c31e11207839.css
new file mode 100644
index 00000000..ec99e712
--- /dev/null
+++ b/public/assets/dark-591813c5ed25b766eda449e80715f9e51238c5defc9e38e60f02c31e11207839.css
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:df567b9eefa4ec9a50433a8e572abc260ed033b21bf7befd0ad52822270c28b6
+size 312
diff --git a/public/assets/dark-591813c5ed25b766eda449e80715f9e51238c5defc9e38e60f02c31e11207839.css.gz b/public/assets/dark-591813c5ed25b766eda449e80715f9e51238c5defc9e38e60f02c31e11207839.css.gz
new file mode 100644
index 00000000..f384cf5a
--- /dev/null
+++ b/public/assets/dark-591813c5ed25b766eda449e80715f9e51238c5defc9e38e60f02c31e11207839.css.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:771de7cf116906bf574d88d9138a236e4725dfdcc8a007d8a02573b3661528ed
+size 162
diff --git a/public/assets/dark-a962b476412caff979202252e033efb9d0a93f7f54a165643f0911ad661907f7.css b/public/assets/dark-a962b476412caff979202252e033efb9d0a93f7f54a165643f0911ad661907f7.css
new file mode 100644
index 00000000..11cfa338
--- /dev/null
+++ b/public/assets/dark-a962b476412caff979202252e033efb9d0a93f7f54a165643f0911ad661907f7.css
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6db6f4c9dad07fca837cedb6bb75b24d46f8971c260797eaf020e13cc9150f8e
+size 360
diff --git a/public/assets/dark-a962b476412caff979202252e033efb9d0a93f7f54a165643f0911ad661907f7.css.gz b/public/assets/dark-a962b476412caff979202252e033efb9d0a93f7f54a165643f0911ad661907f7.css.gz
new file mode 100644
index 00000000..40505259
--- /dev/null
+++ b/public/assets/dark-a962b476412caff979202252e033efb9d0a93f7f54a165643f0911ad661907f7.css.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0aaa4493f784e6d51d90fd90ac8128b00999439ff98fd2e093f06a592a6f967b
+size 186
diff --git a/public/assets/dark-d1cd94a4f79d6af520e59f9f7bd125d6334fdd37205d89d08875e88f1750f8ce.css b/public/assets/dark-d1cd94a4f79d6af520e59f9f7bd125d6334fdd37205d89d08875e88f1750f8ce.css
new file mode 100644
index 00000000..593eda78
--- /dev/null
+++ b/public/assets/dark-d1cd94a4f79d6af520e59f9f7bd125d6334fdd37205d89d08875e88f1750f8ce.css
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ef99eae201c290ed5189749fe0e85730ee6f62e0471118de9fbcb3dca5d2604c
+size 179
diff --git a/public/assets/dark-d1cd94a4f79d6af520e59f9f7bd125d6334fdd37205d89d08875e88f1750f8ce.css.gz b/public/assets/dark-d1cd94a4f79d6af520e59f9f7bd125d6334fdd37205d89d08875e88f1750f8ce.css.gz
new file mode 100644
index 00000000..c955031e
--- /dev/null
+++ b/public/assets/dark-d1cd94a4f79d6af520e59f9f7bd125d6334fdd37205d89d08875e88f1750f8ce.css.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e07977aa1c0c3d1f8c08f8e9082506ecc75c1c30a9413ff622a95a6946de8105
+size 140
diff --git a/public/assets/dark-eb9f64ef7c7dac0cb3eb2b7bd511327d12625e00d4e11216441787288070f7dd.css b/public/assets/dark-eb9f64ef7c7dac0cb3eb2b7bd511327d12625e00d4e11216441787288070f7dd.css
new file mode 100644
index 00000000..4a76781b
--- /dev/null
+++ b/public/assets/dark-eb9f64ef7c7dac0cb3eb2b7bd511327d12625e00d4e11216441787288070f7dd.css
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:577591626adad685d7bdbaab0ae0590841b222586986876b3dacf157c52dffa3
+size 284
diff --git a/public/assets/dark-eb9f64ef7c7dac0cb3eb2b7bd511327d12625e00d4e11216441787288070f7dd.css.gz b/public/assets/dark-eb9f64ef7c7dac0cb3eb2b7bd511327d12625e00d4e11216441787288070f7dd.css.gz
new file mode 100644
index 00000000..74ba6f4f
--- /dev/null
+++ b/public/assets/dark-eb9f64ef7c7dac0cb3eb2b7bd511327d12625e00d4e11216441787288070f7dd.css.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6eff0565120128861b1f1c651ad7eeef39bc680b6b70bcc85aab4cd7abd5728f
+size 156
diff --git a/public/assets/editor-39df1ce9e0468043c2d1c998a38d45fac02beffa1b09088918b9c433c0750b4f.css b/public/assets/editor-39df1ce9e0468043c2d1c998a38d45fac02beffa1b09088918b9c433c0750b4f.css
new file mode 100644
index 00000000..9349b0ad
--- /dev/null
+++ b/public/assets/editor-39df1ce9e0468043c2d1c998a38d45fac02beffa1b09088918b9c433c0750b4f.css
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:403aaacf284c969542b423b9563f4cfa907a81cdf7443da106c3307bc22acd85
+size 2257
diff --git a/public/assets/editor-39df1ce9e0468043c2d1c998a38d45fac02beffa1b09088918b9c433c0750b4f.css.gz b/public/assets/editor-39df1ce9e0468043c2d1c998a38d45fac02beffa1b09088918b9c433c0750b4f.css.gz
new file mode 100644
index 00000000..9b6ecad0
--- /dev/null
+++ b/public/assets/editor-39df1ce9e0468043c2d1c998a38d45fac02beffa1b09088918b9c433c0750b4f.css.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:944d9e5d96f79b8acf12b5e4896d6f60bb9f6fbba5cfb200269ba2b263015f93
+size 687
diff --git a/public/assets/fonts-c51ac4112aa6f7493cea2408ae5778f034eccdd0565697f589654dabb363d79a.css b/public/assets/fonts-c51ac4112aa6f7493cea2408ae5778f034eccdd0565697f589654dabb363d79a.css
new file mode 100644
index 00000000..1ee2f722
--- /dev/null
+++ b/public/assets/fonts-c51ac4112aa6f7493cea2408ae5778f034eccdd0565697f589654dabb363d79a.css
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6cd9507ad59b2d60d4fefd2ee1e20b75c263872ce57bf8d62b6a7fd565d89279
+size 785
diff --git a/public/assets/fonts-c51ac4112aa6f7493cea2408ae5778f034eccdd0565697f589654dabb363d79a.css.gz b/public/assets/fonts-c51ac4112aa6f7493cea2408ae5778f034eccdd0565697f589654dabb363d79a.css.gz
new file mode 100644
index 00000000..6aa4a074
--- /dev/null
+++ b/public/assets/fonts-c51ac4112aa6f7493cea2408ae5778f034eccdd0565697f589654dabb363d79a.css.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e7ef626d9caeb98918792415865ddb3339176fd6f0d64866263b3e69cf1b90cf
+size 379
diff --git a/public/assets/new_editor-9962df2caf2874010146cfd735216677865b7e37bee68c59e997a9aa2198594d.css b/public/assets/new_editor-9962df2caf2874010146cfd735216677865b7e37bee68c59e997a9aa2198594d.css
new file mode 100644
index 00000000..65a56f63
--- /dev/null
+++ b/public/assets/new_editor-9962df2caf2874010146cfd735216677865b7e37bee68c59e997a9aa2198594d.css
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d967abb43a679cd18d84b114ad0616239a67241a8402185d2929e27b02e1a4b3
+size 171
diff --git a/public/assets/new_editor-9962df2caf2874010146cfd735216677865b7e37bee68c59e997a9aa2198594d.css.gz b/public/assets/new_editor-9962df2caf2874010146cfd735216677865b7e37bee68c59e997a9aa2198594d.css.gz
new file mode 100644
index 00000000..07501a46
--- /dev/null
+++ b/public/assets/new_editor-9962df2caf2874010146cfd735216677865b7e37bee68c59e997a9aa2198594d.css.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6d29a581f5f246d75366cda62749a4a9166ad9daeb6bfcc168d7bedc9d973dda
+size 115
diff --git a/public/assets/sutty-08b30df17da83a32911e3bb4fa0a2c967148a2f98020a63b6c171c17c94bf05d.svg.gz b/public/assets/sutty-08b30df17da83a32911e3bb4fa0a2c967148a2f98020a63b6c171c17c94bf05d.svg.gz
index dffee65e..11eacf60 100644
--- a/public/assets/sutty-08b30df17da83a32911e3bb4fa0a2c967148a2f98020a63b6c171c17c94bf05d.svg.gz
+++ b/public/assets/sutty-08b30df17da83a32911e3bb4fa0a2c967148a2f98020a63b6c171c17c94bf05d.svg.gz
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:19973f783284df5608ec79a4fa46dce362f5626112c218266f04329b01049c43
+oid sha256:b96644799494b8c2117ce68d763c392418dbb1b065ffc3d2c52061d9a046ed7f
size 943
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 00000000..f00037df
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,50 @@
+
+
+{
+ "name": "Sutty",
+ "short_name": "Sutty",
+ "start_url": ".",
+ "display": "standalone",
+ "background_color": "#fff",
+ "description": "Sutty crea sitios seguros, veloces y visibles",
+ "icons": [
+
+
+ {
+ "src": "assets/images/icon48.png",
+ "sizes": "48x48",
+ "type": "image/png"
+ },
+
+ {
+ "src": "assets/images/icon72.png",
+ "sizes": "72x72",
+ "type": "image/png"
+ },
+
+ {
+ "src": "assets/images/icon96.png",
+ "sizes": "96x96",
+ "type": "image/png"
+ },
+
+ {
+ "src": "assets/images/icon144.png",
+ "sizes": "144x144",
+ "type": "image/png"
+ },
+
+ {
+ "src": "assets/images/icon168.png",
+ "sizes": "168x168",
+ "type": "image/png"
+ },
+
+ {
+ "src": "assets/images/icon192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ }
+
+ ]
+}
diff --git a/public/packs/css/application-1224e21e.css b/public/packs/css/application-1224e21e.css
deleted file mode 100644
index 390ac1f2..00000000
--- a/public/packs/css/application-1224e21e.css
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:a7ffc74f9219623a13902d9ac806b9e71cfdabc2428e1f6ae4015da56cb7c7d9
-size 49314
diff --git a/public/packs/css/application-1224e21e.css.br b/public/packs/css/application-1224e21e.css.br
deleted file mode 100644
index 1f5776e2..00000000
--- a/public/packs/css/application-1224e21e.css.br
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:68809099d5fd771490c8eee922fe59c2be223a7b3ea6bec6889bc26380dcc788
-size 10017
diff --git a/public/packs/css/application-1224e21e.css.gz b/public/packs/css/application-1224e21e.css.gz
deleted file mode 100644
index 3784a199..00000000
--- a/public/packs/css/application-1224e21e.css.gz
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:8058f9e1c5cfdf8de6d896fe9dd138b527e2409e1c467f79b376f75fa0c24b76
-size 12355
diff --git a/public/packs/css/application-15f863ee.css b/public/packs/css/application-15f863ee.css
new file mode 100644
index 00000000..d96745dc
--- /dev/null
+++ b/public/packs/css/application-15f863ee.css
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ebca4550066085fdfe90f08bff290031dca8f419fa5cdfa762e5b5b0eeb8672c
+size 50272
diff --git a/public/packs/css/application-15f863ee.css.br b/public/packs/css/application-15f863ee.css.br
new file mode 100644
index 00000000..871b44a9
--- /dev/null
+++ b/public/packs/css/application-15f863ee.css.br
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4c1c3accf5782d6a01bcf0e50c975df9cac939efc9c7f27750afe79eeab688bc
+size 10205
diff --git a/public/packs/css/application-15f863ee.css.gz b/public/packs/css/application-15f863ee.css.gz
new file mode 100644
index 00000000..5372b328
--- /dev/null
+++ b/public/packs/css/application-15f863ee.css.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9a3a745f914fd1c353863e504886c54522ff4dd40e66cbfaac5759ea7ed9c46f
+size 12590
diff --git a/public/packs/js/application-79e50106d8ccf849b469.js b/public/packs/js/application-79e50106d8ccf849b469.js
new file mode 100644
index 00000000..f751c3be
--- /dev/null
+++ b/public/packs/js/application-79e50106d8ccf849b469.js
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:18980ff211725989c2a867279ba72b8c9c822bdc55d9f50cb8498bfe7c12f8b0
+size 1706458
diff --git a/public/packs/js/application-79e50106d8ccf849b469.js.LICENSE.txt b/public/packs/js/application-79e50106d8ccf849b469.js.LICENSE.txt
new file mode 100644
index 00000000..dfe27ce7
--- /dev/null
+++ b/public/packs/js/application-79e50106d8ccf849b469.js.LICENSE.txt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7073b760337ff91f74933ece915ce12f8653f990f607a0925cc002dd610fa0f9
+size 1097
diff --git a/public/packs/js/application-79e50106d8ccf849b469.js.br b/public/packs/js/application-79e50106d8ccf849b469.js.br
new file mode 100644
index 00000000..5ce65c1a
--- /dev/null
+++ b/public/packs/js/application-79e50106d8ccf849b469.js.br
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:106efea10ec8018537ca31187a7754b3ec20963638875f8329eb68f86db3e4c9
+size 373982
diff --git a/public/packs/js/application-79e50106d8ccf849b469.js.gz b/public/packs/js/application-79e50106d8ccf849b469.js.gz
new file mode 100644
index 00000000..f46ed590
--- /dev/null
+++ b/public/packs/js/application-79e50106d8ccf849b469.js.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7646aa40f245e79993bbf974d0ec74b5c835fb4318aaa1c5387fa6c6c28ec1b2
+size 494499
diff --git a/public/packs/js/application-79e50106d8ccf849b469.js.map b/public/packs/js/application-79e50106d8ccf849b469.js.map
new file mode 100644
index 00000000..e14a6a9d
--- /dev/null
+++ b/public/packs/js/application-79e50106d8ccf849b469.js.map
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:73afaf44af46b1a63a599a16d613f215969a46f7f844cdd691bfb9da85d533ba
+size 6555771
diff --git a/public/packs/js/application-79e50106d8ccf849b469.js.map.br b/public/packs/js/application-79e50106d8ccf849b469.js.map.br
new file mode 100644
index 00000000..11ddf676
--- /dev/null
+++ b/public/packs/js/application-79e50106d8ccf849b469.js.map.br
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4e27c112de133d65a2d2fff17c3c7f98c92ea982df5430e5d789fa1d88f98df1
+size 1405059
diff --git a/public/packs/js/application-79e50106d8ccf849b469.js.map.gz b/public/packs/js/application-79e50106d8ccf849b469.js.map.gz
new file mode 100644
index 00000000..ef43a6fd
--- /dev/null
+++ b/public/packs/js/application-79e50106d8ccf849b469.js.map.gz
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b9af7ad036ab9555b7b72da2972cfc044daa48b71f7fc4b53695f7ad8f5a7d75
+size 1741822
diff --git a/public/packs/js/application-d4a959210a82d3d1b10f.js b/public/packs/js/application-d4a959210a82d3d1b10f.js
deleted file mode 100644
index ae056684..00000000
--- a/public/packs/js/application-d4a959210a82d3d1b10f.js
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:1d178fb353afcf2dedc8bba8ce4b978f0bc93f679479a7f67c0473e25324a72c
-size 1516360
diff --git a/public/packs/js/application-d4a959210a82d3d1b10f.js.LICENSE.txt b/public/packs/js/application-d4a959210a82d3d1b10f.js.LICENSE.txt
deleted file mode 100644
index 979d1ab9..00000000
--- a/public/packs/js/application-d4a959210a82d3d1b10f.js.LICENSE.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:c3b9ae1697c4b8a404afe77afe035de28b7f4880e9f52caac82620bb8d8ed495
-size 854
diff --git a/public/packs/js/application-d4a959210a82d3d1b10f.js.br b/public/packs/js/application-d4a959210a82d3d1b10f.js.br
deleted file mode 100644
index b7a543a0..00000000
--- a/public/packs/js/application-d4a959210a82d3d1b10f.js.br
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:f37b681c0c2989dba2d59695f7d3d38c9357edab713b2b5899bf2c20dbed1f11
-size 333228
diff --git a/public/packs/js/application-d4a959210a82d3d1b10f.js.gz b/public/packs/js/application-d4a959210a82d3d1b10f.js.gz
deleted file mode 100644
index f800b3fd..00000000
--- a/public/packs/js/application-d4a959210a82d3d1b10f.js.gz
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:a34e726274558a688517e19a1761f028072b7a6f614b4d1ec6f8609e61443bb4
-size 441095
diff --git a/public/packs/js/application-d4a959210a82d3d1b10f.js.map b/public/packs/js/application-d4a959210a82d3d1b10f.js.map
deleted file mode 100644
index 76a8fd29..00000000
--- a/public/packs/js/application-d4a959210a82d3d1b10f.js.map
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:d5c9c622b3d7a39cf332a95f1877dc8d5cbec844fa99cb55c75e45dfed5531dd
-size 5988200
diff --git a/public/packs/js/application-d4a959210a82d3d1b10f.js.map.br b/public/packs/js/application-d4a959210a82d3d1b10f.js.map.br
deleted file mode 100644
index a9d2dce3..00000000
--- a/public/packs/js/application-d4a959210a82d3d1b10f.js.map.br
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:d5242fa25b04407204920fb98a250ea9af7de4d3575ea1dcb79801f2c002fb8f
-size 1279231
diff --git a/public/packs/js/application-d4a959210a82d3d1b10f.js.map.gz b/public/packs/js/application-d4a959210a82d3d1b10f.js.map.gz
deleted file mode 100644
index ffbbaff0..00000000
--- a/public/packs/js/application-d4a959210a82d3d1b10f.js.map.gz
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:d70208139d5de996bc6a01daacf3fc7e07edf975296795cae487e71e2c198e07
-size 1583975
diff --git a/public/packs/manifest.json b/public/packs/manifest.json
index d0f77c7e..301645ef 100644
--- a/public/packs/manifest.json
+++ b/public/packs/manifest.json
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0e5e2ddeee2bb351e8f9e0b16d28fcebd7314227abdffa65e02e83755db591d6
+oid sha256:3f403215e0709092988b21a3cfcf882c92dbc560566cbfb06185718e5c50dceb
size 1426
diff --git a/public/packs/manifest.json.br b/public/packs/manifest.json.br
index 76978873..f29a93ba 100644
--- a/public/packs/manifest.json.br
+++ b/public/packs/manifest.json.br
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:a7c9ab4526ce1ce929b4d0c242dee97cacc9f79fac73948c42a4167494e251e1
-size 321
+oid sha256:d164815536e47cafc7798e46921a383e9a8583c43084e86e66bbdc39c460a4be
+size 320
diff --git a/public/packs/manifest.json.gz b/public/packs/manifest.json.gz
index c691abe7..bbea6129 100644
--- a/public/packs/manifest.json.gz
+++ b/public/packs/manifest.json.gz
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:caf56db4d1167dd81eadb2da3a1fb6d14bf9f668381d4aa8295f333bcd649f00
-size 365
+oid sha256:18e109f387d8f17c4e7ddf4f9d184d40767de91187ae9a34d454b35808e3f579
+size 366
diff --git a/test/controllers/posts_controller_test.rb b/test/controllers/posts_controller_test.rb
index b8c9f560..3349f09b 100644
--- a/test/controllers/posts_controller_test.rb
+++ b/test/controllers/posts_controller_test.rb
@@ -150,7 +150,7 @@ class PostsControllerTest < ActionDispatch::IntegrationTest
end
posts = @site.posts(**lang)
- reorder = Hash[posts.map { |p| p.uuid.value }.shuffle.each_with_index.to_a]
+ reorder = posts.map { |p| p.uuid.value }.shuffle.each_with_index.to_a.to_h
post site_posts_reorder_url(@site),
headers: @authorization,
@@ -159,10 +159,18 @@ class PostsControllerTest < ActionDispatch::IntegrationTest
@site = Site.find @site.id
assert_equal reorder,
- Hash[@site.posts(**lang).map do |p|
+ @site.posts(**lang).map do |p|
[p.uuid.value, p.order.value]
- end]
+ end.to_h
assert_equal I18n.t('post_service.reorder'),
@site.repository.rugged.head.target.message
end
+
+ test 'si hay algún error se recupera' do
+ File.open(File.join(@site.path, '_es', "#{Date.today}-#{SecureRandom.hex}.markdown"), 'w') do |f|
+ f.write ''
+ end
+
+ get site_posts_url(@site), headers: @authorization
+ end
end
diff --git a/yarn.lock b/yarn.lock
index fc6ae7cb..0dece94d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1841,6 +1841,16 @@
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2"
integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==
+"@hotwired/stimulus-webpack-helpers@^1.0.1":
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/@hotwired/stimulus-webpack-helpers/-/stimulus-webpack-helpers-1.0.1.tgz#4cd74487adeca576c9865ac2b9fe5cb20cef16dd"
+ integrity sha512-wa/zupVG0eWxRYJjC1IiPBdt3Lruv0RqGN+/DTMmUWUyMAEB27KXmVY6a8YpUVTM7QwVuaLNGW4EqDgrS2upXQ==
+
+"@hotwired/stimulus@^3.2.2":
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.2.2.tgz#071aab59c600fed95b97939e605ff261a4251608"
+ integrity sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==
+
"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2":
version "0.3.3"
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098"
@@ -1984,6 +1994,11 @@
linkifyjs "^4.1.1"
prosemirror-svelte-nodeview "^1.0.2"
+"@suttyweb/htmx.org@2.0.0":
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/@suttyweb/htmx.org/-/htmx.org-2.0.0.tgz#44435d834d143ae9b60daa454f68f8e72e6ebd7f"
+ integrity sha512-EJk9s8judGLIZ6c9N779z91WHPIfAkwkVY5QF7WH2ZT2Kt03k/hAoy7P4NjYreFIQcIo8d+TU/CIhViCmB4c0Q==
+
"@types/caseless@*":
version "0.12.2"
resolved "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz"
@@ -4575,11 +4590,6 @@ html-entities@^1.3.1:
resolved "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz"
integrity sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==
-htmx.org@^1.9.11:
- version "1.9.11"
- resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.9.11.tgz#00192041ee682d6ca7146d0fbd901169ffe72d87"
- integrity sha512-WlVuICn8dfNOOgYmdYzYG8zSnP3++AdHkMHooQAzGZObWpVXYathpz/I37ycF4zikR6YduzfCvEcxk20JkIUsw==
-
http-deceiver@^1.2.7:
version "1.2.7"
resolved "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz"