mirror of
https://github.com/indentlabs/notebook.git
synced 2025-10-26 11:19:22 +00:00
template editor tweaks from claudy
This commit is contained in:
parent
df56671ed3
commit
f2811388d7
@ -39,18 +39,24 @@ class AttributeCategoriesController < ContentController
|
||||
return redirect_back fallback_location: root_path
|
||||
end
|
||||
|
||||
# Track what actually changed
|
||||
original_hidden = @attribute_category.hidden
|
||||
original_position = @attribute_category.position
|
||||
|
||||
if @attribute_category.update(content_params)
|
||||
@content = @attribute_category
|
||||
|
||||
# Generate specific message based on what was updated
|
||||
message = if content_params[:hidden] && content_params[:hidden] == 'true'
|
||||
"#{@attribute_category.label} category is now hidden"
|
||||
elsif content_params[:hidden] && content_params[:hidden] == 'false'
|
||||
"#{@attribute_category.label} category is now visible"
|
||||
elsif content_params[:position]
|
||||
"#{@attribute_category.label} category moved to position #{content_params[:position]}"
|
||||
# Generate specific message based on what was actually updated
|
||||
message = if @attribute_category.hidden != original_hidden
|
||||
if @attribute_category.hidden?
|
||||
"#{@attribute_category.label} category is now hidden"
|
||||
else
|
||||
"#{@attribute_category.label} category is now visible"
|
||||
end
|
||||
elsif @attribute_category.position != original_position
|
||||
"#{@attribute_category.label} category moved to position #{@attribute_category.position}"
|
||||
else
|
||||
"#{@attribute_category.label} category updated successfully!"
|
||||
"#{@attribute_category.label} category saved successfully!"
|
||||
end
|
||||
|
||||
successful_response(@attribute_category, message)
|
||||
@ -100,6 +106,36 @@ class AttributeCategoriesController < ContentController
|
||||
end
|
||||
end
|
||||
|
||||
def sort
|
||||
content_id = params[:content_id]
|
||||
intended_position = params[:intended_position].to_i
|
||||
|
||||
category = current_user.attribute_categories.find_by(id: content_id)
|
||||
|
||||
unless category
|
||||
render json: { error: "Category not found" }, status: :not_found
|
||||
return
|
||||
end
|
||||
|
||||
unless category.updatable_by?(current_user)
|
||||
render json: { error: "You don't have permission to reorder that category" }, status: :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
# Use acts_as_list to move to the intended position
|
||||
category.insert_at(intended_position + 1) # acts_as_list is 1-indexed
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
message: "#{category.label} category moved to position #{intended_position + 1}",
|
||||
category: {
|
||||
id: category.id,
|
||||
position: category.position,
|
||||
label: category.label
|
||||
}
|
||||
}, status: :ok
|
||||
end
|
||||
|
||||
def suggest
|
||||
entity_type = params.fetch(:content_type, '').downcase
|
||||
|
||||
|
||||
@ -54,18 +54,24 @@ class AttributeFieldsController < ContentController
|
||||
cleaned_params[:field_options][:linkable_types] = cleaned_params[:field_options][:linkable_types].reject(&:blank?)
|
||||
end
|
||||
|
||||
# Track what actually changed
|
||||
original_hidden = @attribute_field.hidden
|
||||
original_position = @attribute_field.position
|
||||
|
||||
if @attribute_field.update(cleaned_params.merge({ migrated_from_legacy: true }))
|
||||
@content = @attribute_field
|
||||
|
||||
# Generate specific message based on what was updated
|
||||
message = if content_params[:hidden] && content_params[:hidden] == 'true'
|
||||
"#{@attribute_field.label} field is now hidden"
|
||||
elsif content_params[:hidden] && content_params[:hidden] == 'false'
|
||||
"#{@attribute_field.label} field is now visible"
|
||||
elsif content_params[:position]
|
||||
"#{@attribute_field.label} field moved to position #{content_params[:position]}"
|
||||
# Generate specific message based on what was actually updated
|
||||
message = if @attribute_field.hidden != original_hidden
|
||||
if @attribute_field.hidden?
|
||||
"#{@attribute_field.label} field is now hidden"
|
||||
else
|
||||
"#{@attribute_field.label} field is now visible"
|
||||
end
|
||||
elsif @attribute_field.position != original_position
|
||||
"#{@attribute_field.label} field moved to position #{@attribute_field.position}"
|
||||
else
|
||||
"#{@attribute_field.label} field updated successfully!"
|
||||
"#{@attribute_field.label} field saved successfully!"
|
||||
end
|
||||
|
||||
successful_response(@attribute_field, message)
|
||||
@ -78,6 +84,46 @@ class AttributeFieldsController < ContentController
|
||||
end
|
||||
end
|
||||
|
||||
def sort
|
||||
content_id = params[:content_id]
|
||||
intended_position = params[:intended_position].to_i
|
||||
attribute_category_id = params[:attribute_category_id]
|
||||
|
||||
field = current_user.attribute_fields.find_by(id: content_id)
|
||||
|
||||
unless field
|
||||
render json: { error: "Field not found" }, status: :not_found
|
||||
return
|
||||
end
|
||||
|
||||
unless field.updatable_by?(current_user)
|
||||
render json: { error: "You don't have permission to reorder that field" }, status: :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
# If moving to a different category, update the category first
|
||||
if attribute_category_id && field.attribute_category_id.to_s != attribute_category_id.to_s
|
||||
new_category = current_user.attribute_categories.find_by(id: attribute_category_id)
|
||||
if new_category
|
||||
field.update(attribute_category_id: new_category.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Use acts_as_list to move to the intended position within the category
|
||||
field.insert_at(intended_position + 1) # acts_as_list is 1-indexed
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
message: "#{field.label} field moved to position #{intended_position + 1}",
|
||||
field: {
|
||||
id: field.id,
|
||||
position: field.position,
|
||||
label: field.label,
|
||||
attribute_category_id: field.attribute_category_id
|
||||
}
|
||||
}, status: :ok
|
||||
end
|
||||
|
||||
def suggest
|
||||
entity_type = params.fetch(:content_type, '').downcase
|
||||
category = params.fetch(:category, '')
|
||||
|
||||
@ -1,11 +1,496 @@
|
||||
# Controller for CRUD actions related to any content
|
||||
class ContentController < ApplicationController
|
||||
before_action :authenticate_user!, except: [:show, :changelog]
|
||||
before_action :set_content_type, only: [:attributes, :attributes_tailwind, :export_template]
|
||||
before_action :set_attributes_content_type, only: [:attributes, :attributes_tailwind, :export_template]
|
||||
# frozen_string_literal: true
|
||||
|
||||
# TODO: we should probably spin off an Api::ContentController for #api_sort and anything else
|
||||
# api-wise we need
|
||||
class ContentController < ApplicationController
|
||||
layout 'tailwind', only: [:index, :show, :gallery, :references, :deleted, :attributes_tailwind]
|
||||
|
||||
before_action :authenticate_user!, except: [:show, :changelog, :api_sort, :gallery] \
|
||||
+ Rails.application.config.content_types[:all_non_universe].map { |type| type.name.downcase.pluralize.to_sym }
|
||||
|
||||
skip_before_action :cache_most_used_page_information, only: [
|
||||
:name_field_update, :text_field_update, :tags_field_update, :universe_field_update, :api_sort
|
||||
]
|
||||
|
||||
before_action :migrate_old_style_field_values, only: [:show, :edit]
|
||||
|
||||
before_action :cache_linkable_content_for_each_content_type, only: [:new, :show, :edit, :index]
|
||||
|
||||
before_action :set_attributes_content_type, only: [:attributes, :attributes_tailwind, :export_template, :reset_template]
|
||||
|
||||
before_action :set_navbar_color, except: [:api_sort]
|
||||
before_action :set_navbar_actions, except: [:deleted, :api_sort]
|
||||
before_action :set_sidenav_expansion, except: [:api_sort]
|
||||
|
||||
def index
|
||||
@content_type_class = content_type_from_controller(self.class)
|
||||
@content_type_name = @content_type_class.name
|
||||
pluralized_content_name = @content_type_class.name.downcase.pluralize
|
||||
|
||||
@page_title = "My #{pluralized_content_name}"
|
||||
|
||||
# Create the default fields for this user if they don't have any already
|
||||
# TODO: uh, this probably doesn't belong here!
|
||||
@content_type_class.attribute_categories(current_user)
|
||||
|
||||
# Linkables cache is already scoped per-universe, includes contributor pages
|
||||
@content = @linkables_raw.fetch(@content_type_class.name, [])
|
||||
|
||||
@show_scope_notice = @universe_scope.present? && @content_type_class != Universe
|
||||
|
||||
# Filters
|
||||
@page_tags = PageTag.where(
|
||||
page_type: @content_type_class.name,
|
||||
page_id: @content.pluck(:id)
|
||||
).order(:tag)
|
||||
@filtered_page_tags = []
|
||||
if params.key?(:slug)
|
||||
@filtered_page_tags = @page_tags.where(slug: params[:slug])
|
||||
@content.select! { |content| @filtered_page_tags.pluck(:page_id).include?(content.id) }
|
||||
end
|
||||
@page_tags = @page_tags.uniq(&:tag)
|
||||
|
||||
if params.key?(:favorite_only)
|
||||
@content.select!(&:favorite?)
|
||||
end
|
||||
|
||||
@content = @content.sort_by {|x| [x.favorite? ? 0 : 1, x.name] }
|
||||
@folders = current_user
|
||||
.folders
|
||||
.where(context: @content_type_name, parent_folder_id: nil)
|
||||
.order('title ASC')
|
||||
|
||||
@questioned_content = @content.sample
|
||||
@attribute_field_to_question = SerendipitousService.question_for(@questioned_content)
|
||||
|
||||
# Query for both regular and pinned images
|
||||
image_uploads = ImageUpload.where(
|
||||
content_type: @content_type_class.name,
|
||||
content_id: @content.pluck(:id)
|
||||
)
|
||||
|
||||
# Group by content but prioritize pinned images
|
||||
@random_image_including_private_pool_cache = {}
|
||||
image_uploads.group_by { |image| [image.content_type, image.content_id] }.each do |key, images|
|
||||
# Check for pinned images first that have a valid src
|
||||
pinned_image = images.find { |img| img.pinned }
|
||||
if pinned_image && pinned_image.src_file_name.present?
|
||||
@random_image_including_private_pool_cache[key] = [pinned_image]
|
||||
else
|
||||
# Use all valid images if no valid pinned image
|
||||
@random_image_including_private_pool_cache[key] = images.select { |img| img.src_file_name.present? }
|
||||
end
|
||||
end
|
||||
|
||||
@saved_basil_commissions = BasilCommission.where(
|
||||
entity_type: @content_type_class.name,
|
||||
entity_id: @content.pluck(:id)
|
||||
).where.not(saved_at: nil)
|
||||
.group_by { |commission| [commission.entity_type, commission.entity_id] }
|
||||
|
||||
# Uh, do we ever actually make JSON requests to logged-in user pages?
|
||||
respond_to do |format|
|
||||
format.html { render 'content/index' }
|
||||
format.json { render json: @content }
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
content_type = content_type_from_controller(self.class)
|
||||
return redirect_to(root_path, notice: "That page doesn't exist!", status: :not_found) unless valid_content_types.include?(content_type.name)
|
||||
|
||||
@content = content_type.find_by(id: params[:id])
|
||||
return redirect_to(root_path, notice: "You don't have permission to view that content.", status: :not_found) if @content.nil?
|
||||
|
||||
return redirect_to(root_path) if @content.user.nil? # deleted user's content
|
||||
return if ENV.key?('CONTENT_BLACKLIST') && ENV['CONTENT_BLACKLIST'].split(',').include?(@content.user.try(:email))
|
||||
|
||||
@serialized_content = ContentSerializer.new(@content)
|
||||
|
||||
# For basil images, assume they're all public for now since there's no privacy column
|
||||
@basil_images = BasilCommission.where(entity: @content)
|
||||
.where.not(saved_at: nil)
|
||||
|
||||
if @content.updatable_by?(current_user)
|
||||
@suggested_page_tags = (
|
||||
current_user.page_tags.where(page_type: content_type.name).pluck(:tag) +
|
||||
PageTagService.suggested_tags_for(content_type.name)
|
||||
).uniq
|
||||
end
|
||||
|
||||
if (current_user || User.new).can_read?(@content)
|
||||
respond_to do |format|
|
||||
format.html { render 'content/show', locals: { content: @content } }
|
||||
format.json { render json: @serialized_content.data }
|
||||
end
|
||||
else
|
||||
return redirect_to root_path, notice: "You don't have permission to view that content."
|
||||
end
|
||||
end
|
||||
|
||||
def gallery
|
||||
content_type = content_type_from_controller(self.class)
|
||||
return redirect_to(root_path, notice: "That page doesn't exist!") unless valid_content_types.include?(content_type.name)
|
||||
|
||||
@content = content_type.find_by(id: params[:id])
|
||||
return redirect_to(root_path, notice: "You don't have permission to view that content.") if @content.nil?
|
||||
|
||||
return redirect_to(root_path) if @content.user.nil? # deleted user's content
|
||||
return if ENV.key?('CONTENT_BLACKLIST') && ENV['CONTENT_BLACKLIST'].split(',').include?(@content.user.try(:email))
|
||||
|
||||
if (current_user || User.new).can_read?(@content)
|
||||
# Serialize content for overview section
|
||||
@serialized_content = ContentSerializer.new(@content)
|
||||
|
||||
# Get all images for this content with proper ordering
|
||||
# Only show private images to the owner or contributors
|
||||
is_owner_or_contributor = false
|
||||
# Check if the user is the owner or a contributor
|
||||
if current_user.present? && (@content.user == current_user ||
|
||||
(@content.respond_to?(:universe_id) &&
|
||||
@content.universe_id.present? &&
|
||||
current_user.try(:contributable_universe_ids).to_a.include?(@content.universe_id)))
|
||||
is_owner_or_contributor = true
|
||||
@images = ImageUpload.where(content_type: @content.class.name, content_id: @content.id).ordered
|
||||
else
|
||||
@images = ImageUpload.where(content_type: @content.class.name, content_id: @content.id, privacy: 'public').ordered
|
||||
end
|
||||
|
||||
# Get additional context information
|
||||
if @content.is_a?(Universe)
|
||||
# Universe objects don't have a universe_id field
|
||||
@universe = nil
|
||||
@other_content = []
|
||||
else
|
||||
@universe = @content.universe_id.present? ? Universe.find_by(id: @content.universe_id) : nil
|
||||
@other_content = @content.universe_id.present? ?
|
||||
content_type.where(universe_id: @content.universe_id).where.not(id: @content.id).limit(5) : []
|
||||
end
|
||||
|
||||
# Include basil images too with proper ordering
|
||||
@basil_images = BasilCommission.where(entity: @content).where.not(saved_at: nil).ordered
|
||||
|
||||
render 'content/gallery'
|
||||
else
|
||||
return redirect_to root_path, notice: "You don't have permission to view that content."
|
||||
end
|
||||
end
|
||||
|
||||
def references
|
||||
content_type = content_type_from_controller(self.class)
|
||||
return redirect_to(root_path, notice: "That page doesn't exist!") unless valid_content_types.include?(content_type.name)
|
||||
|
||||
@content = content_type.find_by(id: params[:id])
|
||||
return redirect_to(root_path, notice: "You don't have permission to view that content.") if @content.nil?
|
||||
|
||||
return redirect_to(root_path) if @content.user.nil? # deleted user's content
|
||||
return if ENV.key?('CONTENT_BLACKLIST') && ENV['CONTENT_BLACKLIST'].split(',').include?(@content.user.try(:email))
|
||||
|
||||
@serialized_content = ContentSerializer.new(@content)
|
||||
|
||||
analysis_ids = DocumentEntity.where(entity: @content).pluck(:document_analysis_id)
|
||||
document_ids = DocumentAnalysis.where(id: analysis_ids).pluck(:document_id)
|
||||
@documents = Document.where(id: document_ids)
|
||||
@references = @content.incoming_page_references.preload(:referencing_page)
|
||||
@mentioning_attributes = Attribute.where(
|
||||
attribute_field_id: @references.pluck(:attribute_field_id),
|
||||
entity_id: @references.pluck(:referencing_page_id)
|
||||
)
|
||||
end
|
||||
|
||||
def new
|
||||
@content = content_type_from_controller(self.class)
|
||||
.new(user: current_user)
|
||||
.tap { |content|
|
||||
content.name = "New #{content.class.name}"
|
||||
content.universe_id = @universe_scope.try(:id) if content.respond_to?(:universe_id)
|
||||
}
|
||||
|
||||
current_users_categories_and_fields = @content.class.attribute_categories(current_user)
|
||||
if current_users_categories_and_fields.empty?
|
||||
content_type_from_controller(self.class).create_default_attribute_categories(current_user)
|
||||
current_users_categories_and_fields = @content.class.attribute_categories(current_user)
|
||||
end
|
||||
|
||||
if user_signed_in? && current_user.can_create?(@content.class) \
|
||||
|| PermissionService.user_has_active_promotion_for_this_content_type(user: current_user, content_type: @content.class.name)
|
||||
|
||||
# For users who are creating premium content in a collaborated universe without premium of their own
|
||||
# we want to default that content into one of their collaborated unvierses.
|
||||
if !current_user.on_premium_plan? && Rails.application.config.content_types[:premium].map(&:name).include?(@content.class.name)
|
||||
@content.universe_id = current_user.contributable_universes.first.try(:id)
|
||||
end
|
||||
|
||||
if params.key?(:document_entity)
|
||||
entity = DocumentEntity.find_by(id: params.fetch(:document_entity).to_i)
|
||||
if entity.document_owner == current_user
|
||||
# Link the new page to the document entity
|
||||
@content.name = entity.text # cached name value
|
||||
@content.document_entity_id = entity.id
|
||||
|
||||
# Since we're creating a new page here, we need to make sure we save it before requesting
|
||||
# a name field, since they're keyed off content IDs (and we don't have an ID before saving).
|
||||
@content.save!
|
||||
|
||||
# Update the actual AttributeField's value for this page's name also
|
||||
@content.set_name_field_value(entity.text)
|
||||
end
|
||||
end
|
||||
|
||||
@content.save!
|
||||
|
||||
# If the user doesn't have this content type enabled, go ahead and automatically enable it for them
|
||||
current_user.user_content_type_activators.find_or_create_by(content_type: @content.class.name)
|
||||
|
||||
return redirect_to polymorphic_path(@content, editing: true)
|
||||
else
|
||||
return redirect_to(subscription_path, notice: "#{@content.class.name.pluralize} require a Premium subscription to create.")
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
content_type_class = content_type_from_controller(self.class)
|
||||
@content = content_type_class.find_by(id: params[:id])
|
||||
if @content.nil?
|
||||
return redirect_to(root_path,
|
||||
notice: "Either this #{content_type_class.name.downcase} doesn't exist, or you don't have access to view it."
|
||||
)
|
||||
end
|
||||
|
||||
@serialized_content = ContentSerializer.new(@content)
|
||||
@suggested_page_tags = (
|
||||
current_user.page_tags.where(page_type: content_type_class.name).pluck(:tag) +
|
||||
PageTagService.suggested_tags_for(content_type_class.name)
|
||||
).uniq - @serialized_content.page_tags
|
||||
|
||||
unless @content.updatable_by? current_user
|
||||
return redirect_to @content, notice: t(:no_do_permission)
|
||||
end
|
||||
|
||||
@random_image_including_private_pool_cache = ImageUpload.where(
|
||||
user_id: current_user.id,
|
||||
).group_by { |image| [image.content_type, image.content_id] }
|
||||
@basil_images = BasilCommission.where(entity: @content)
|
||||
.where.not(saved_at: nil)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { render 'content/edit', locals: { content: @content } }
|
||||
format.json { render json: @content }
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
content_type = content_type_from_controller(self.class)
|
||||
initialize_object
|
||||
|
||||
unless current_user.can_create?(content_type) \
|
||||
|| PermissionService.user_has_active_promotion_for_this_content_type(user: current_user, content_type: content_type.name)
|
||||
|
||||
return redirect_back(fallback_location: root_path, notice: "Creating this type of page requires an active Premium subscription.")
|
||||
end
|
||||
|
||||
# Default names to untitled until one has been set
|
||||
unless [AttributeCategory, AttributeField, Attribute].map(&:name).include?(@content.class.name)
|
||||
@content.name ||= "Untitled #{content_type.name.downcase}"
|
||||
end
|
||||
|
||||
# Default owner to the current user
|
||||
@content.user_id ||= current_user.id
|
||||
|
||||
if @content.save
|
||||
cache_params = {}
|
||||
cache_params[:name] = @content.name_field_value unless [AttributeCategory, AttributeField].include?(@content.class)
|
||||
cache_params[:universe] = @content.universe_field_value if self.respond_to?(:universe_id)
|
||||
@content.update(cache_params) if cache_params.any?
|
||||
|
||||
if params.key? 'image_uploads'
|
||||
upload_files params['image_uploads'], content_type.name, @content.id
|
||||
end
|
||||
|
||||
update_page_tags if @content.respond_to?(:page_tags)
|
||||
|
||||
if content_params.key?('document_entity_id')
|
||||
document_entity = DocumentEntity.find_by(id: content_params['document_entity_id'].to_i)
|
||||
if document_entity.document_owner == current_user
|
||||
document_entity.update(entity_id: @content.reload.id)
|
||||
end
|
||||
end
|
||||
|
||||
# If the user doesn't have this content type enabled, go ahead and automatically enable it for them
|
||||
current_user.user_content_type_activators.find_or_create_by(content_type: content_type.name)
|
||||
|
||||
successful_response(content_creation_redirect_url, t(:create_success, model_name: @content.try(:name).presence || humanized_model_name))
|
||||
else
|
||||
failed_response('new', :unprocessable_entity, "Unable to save page. Error code: " + @content.errors.to_json.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
# TODO: most things are stripped out now that we're using per-field updates, so we should
|
||||
# audit what's left of this function and what needs to stay
|
||||
content_type = content_type_from_controller(self.class)
|
||||
@content = content_type.with_deleted.find(params[:id])
|
||||
|
||||
unless @content.updatable_by?(current_user)
|
||||
flash[:notice] = "You don't have permission to edit that!"
|
||||
return redirect_back fallback_location: @content
|
||||
end
|
||||
|
||||
if params.key?('image_uploads')
|
||||
upload_files(params['image_uploads'], content_type.name, @content.id)
|
||||
end
|
||||
|
||||
if @content.is_a?(Universe) && params.key?('contributors') && @content.user == current_user
|
||||
params[:contributors][:email].reject(&:blank?).each do |email|
|
||||
ContributorService.invite_contributor_to_universe(universe: @content, email: email.downcase)
|
||||
end
|
||||
end
|
||||
|
||||
# update_page_tags if @content.respond_to?(:page_tags)
|
||||
|
||||
if @content.user == current_user
|
||||
# todo this needs some extra validation probably to ensure each attribute is one associated with this page
|
||||
update_success = @content.reload.update(content_params)
|
||||
else
|
||||
# Exclude fields only the real owner can edit
|
||||
#todo move field list somewhere when it grows
|
||||
update_success = @content.update(content_params.except(:universe_id))
|
||||
end
|
||||
|
||||
cache_params = {}
|
||||
# TODO strip relevant logic out to AttributeCategory#update and Attribute#update so we don't need this weird branch
|
||||
cache_params[:name] = @content.name_field_value unless [AttributeCategory, Attribute].include?(@content.class)
|
||||
cache_params[:universe] = @content.universe_field_value if self.respond_to?(:universe_id)
|
||||
@content.update(cache_params) if cache_params.any? && update_success
|
||||
|
||||
if update_success
|
||||
successful_response(@content, t(:update_success, model_name: @content.try(:name).presence || humanized_model_name))
|
||||
else
|
||||
failed_response('edit', :unprocessable_entity, "Unable to save page. Error code: " + @content.errors.to_json)
|
||||
end
|
||||
end
|
||||
|
||||
def toggle_favorite
|
||||
content_type = content_type_from_controller(self.class)
|
||||
@content = content_type.with_deleted.find(params[:id])
|
||||
|
||||
unless @content.updatable_by?(current_user)
|
||||
flash[:notice] = "You don't have permission to edit that!"
|
||||
return redirect_back fallback_location: @content
|
||||
end
|
||||
|
||||
@content.update!(favorite: !@content.favorite)
|
||||
end
|
||||
|
||||
def toggle_archive
|
||||
# todo Since this method is triggered via a GET in floating_action_buttons, a malicious user could technically archive
|
||||
# another user's content if they're able to send that user to a specifically-crafted URL or inject that URL somewhere on
|
||||
# a page (e.g. img src="/characters/1234/toggle_archive"). Since archiving is reversible this seems fine for release, but
|
||||
# is something that should be fixed asap before any abuse happens.
|
||||
|
||||
content_type = content_type_from_controller(self.class)
|
||||
@content = content_type.with_deleted.find(params[:id])
|
||||
|
||||
unless @content.updatable_by?(current_user)
|
||||
flash[:notice] = "You don't have permission to edit that!"
|
||||
return redirect_back fallback_location: @content
|
||||
end
|
||||
|
||||
verb = nil
|
||||
success = if @content.archived?
|
||||
verb = "unarchived"
|
||||
@content.unarchive!
|
||||
else
|
||||
verb = "archived"
|
||||
@content.archive!
|
||||
end
|
||||
|
||||
if success
|
||||
redirect_back(fallback_location: archive_path, notice: "This page has been #{verb}.")
|
||||
else
|
||||
redirect_back(fallback_location: root_path, notice: "Something went wrong while attempting to archive that page.")
|
||||
end
|
||||
end
|
||||
|
||||
def changelog
|
||||
content_type = content_type_from_controller(self.class)
|
||||
return redirect_to root_path unless valid_content_types.include?(content_type.name)
|
||||
@content = content_type.find_by(id: params[:id])
|
||||
return redirect_to(root_path, notice: "You don't have permission to view that content.") if @content.nil?
|
||||
@serialized_content = ContentSerializer.new(@content)
|
||||
return redirect_to(root_path, notice: "You don't have permission to view that content.") unless @content.updatable_by?(current_user || User.new)
|
||||
|
||||
if user_signed_in?
|
||||
@navbar_actions << {
|
||||
label: @serialized_content.name,
|
||||
href: main_app.polymorphic_path(@content)
|
||||
}
|
||||
|
||||
@navbar_actions << {
|
||||
label: 'Changelog',
|
||||
href: send("changelog_#{content_type.name.downcase}_path", @content)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def upload_files image_uploads_list, content_type, content_id
|
||||
image_uploads_list.each do |image_data|
|
||||
image_size_kb = File.size(image_data.tempfile.path) / 1000.0
|
||||
|
||||
if current_user.upload_bandwidth_kb < image_size_kb
|
||||
flash[:alert] = [
|
||||
"At least one of your images failed to upload because you do not have enough upload bandwidth.",
|
||||
"<a href='#{subscription_path}' class='btn white black-text center-align'>Get more</a>"
|
||||
].map { |p| "<p>#{p}</p>" }.join
|
||||
next
|
||||
else
|
||||
current_user.update(upload_bandwidth_kb: current_user.upload_bandwidth_kb - image_size_kb)
|
||||
end
|
||||
|
||||
related_image = ImageUpload.create(
|
||||
user: current_user,
|
||||
content_type: content_type,
|
||||
content_id: content_id,
|
||||
src: image_data,
|
||||
privacy: 'public'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
content_type = content_type_from_controller(self.class)
|
||||
@content = content_type.find_by(id: params[:id])
|
||||
|
||||
unless current_user.can_delete?(@content)
|
||||
return redirect_to :back, notice: "You don't have permission to do that!"
|
||||
end
|
||||
|
||||
cached_page_name = @content.try(:name)
|
||||
@content.destroy
|
||||
|
||||
successful_response(content_deletion_redirect_url, t(:delete_success, model_name: cached_page_name.presence || humanized_model_name))
|
||||
end
|
||||
|
||||
# List all recently-deleted content
|
||||
def deleted
|
||||
@maximum_recovery_time = current_user.on_premium_plan? ? 7.days : 48.hours
|
||||
|
||||
@content_pages = {}
|
||||
@activated_content_types.each do |content_type|
|
||||
@content_pages[content_type] = content_type.constantize
|
||||
.with_deleted
|
||||
.where('deleted_at > ?', @maximum_recovery_time.ago)
|
||||
.where(user_id: current_user.id)
|
||||
end
|
||||
@content_pages["Document"] = current_user.documents
|
||||
.with_deleted
|
||||
.where('documents.deleted_at > ?', @maximum_recovery_time.ago)
|
||||
.includes(:user)
|
||||
|
||||
# Override controller
|
||||
@sidenav_expansion = 'my account'
|
||||
end
|
||||
|
||||
layout 'tailwind'
|
||||
|
||||
def attributes
|
||||
@attribute_categories = @content_type_class
|
||||
.attribute_categories(current_user, show_hidden: true)
|
||||
@ -44,43 +529,413 @@ class ContentController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def reset_template
|
||||
service = TemplateResetService.new(current_user, @content_type)
|
||||
|
||||
if params[:confirm] == 'true'
|
||||
result = service.reset_template!
|
||||
|
||||
respond_to do |format|
|
||||
format.json do
|
||||
if result[:success]
|
||||
render json: {
|
||||
success: true,
|
||||
message: result[:message]
|
||||
}, status: :ok
|
||||
else
|
||||
render json: {
|
||||
success: false,
|
||||
error: result[:error]
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
# Return analysis for confirmation
|
||||
analysis = service.analyze_reset_impact
|
||||
render json: analysis, status: :ok
|
||||
end
|
||||
end
|
||||
|
||||
def toggle_image_pin
|
||||
# Find the image based on type and ID
|
||||
if params[:image_type] == 'image_upload'
|
||||
@image = ImageUpload.find_by(id: params[:image_id])
|
||||
elsif params[:image_type] == 'basil_commission'
|
||||
@image = BasilCommission.find_by(id: params[:image_id])
|
||||
else
|
||||
return render json: { error: 'Invalid image type' }, status: 400
|
||||
end
|
||||
|
||||
# Ensure the image exists and the user has permission to modify it
|
||||
if @image.nil?
|
||||
return render json: { error: 'Image not found' }, status: 404
|
||||
end
|
||||
|
||||
# Check permissions
|
||||
content = params[:image_type] == 'image_upload' ?
|
||||
@image.content :
|
||||
@image.entity
|
||||
|
||||
# Need to check if user owns or contributes to the content directly
|
||||
unless content.user_id == current_user.id ||
|
||||
(content.respond_to?(:universe_id) &&
|
||||
content.universe_id.present? &&
|
||||
current_user.contributable_universe_ids.include?(content.universe_id))
|
||||
return render json: { error: 'Unauthorized' }, status: 403
|
||||
end
|
||||
|
||||
# Are we pinning or unpinning?
|
||||
new_pin_status = !@image.pinned
|
||||
|
||||
# If we're pinning this image (not just unpinning), we need to unpin any other images
|
||||
if new_pin_status
|
||||
# First, unpin any other ImageUploads for this content
|
||||
if content.respond_to?(:image_uploads)
|
||||
content.image_uploads.where(pinned: true).where.not(id: params[:image_type] == 'image_upload' ? params[:image_id] : nil).update_all(pinned: false)
|
||||
end
|
||||
|
||||
# Then, unpin any BasilCommissions for this content
|
||||
if content.respond_to?(:basil_commissions)
|
||||
content.basil_commissions.where(pinned: true).where.not(id: params[:image_type] == 'basil_commission' ? params[:image_id] : nil).update_all(pinned: false)
|
||||
end
|
||||
end
|
||||
|
||||
# Now toggle this image's pin status - force with update_column to avoid callbacks
|
||||
@image.update_column(:pinned, new_pin_status)
|
||||
# Force reload to ensure we have latest pin status
|
||||
@image.reload
|
||||
|
||||
# Clear any cached images to ensure pinned images are shown
|
||||
content.instance_variable_set(:@random_image_including_private_cache, nil)
|
||||
|
||||
# Return the updated status
|
||||
render json: {
|
||||
id: @image.id,
|
||||
type: params[:image_type],
|
||||
pinned: @image.pinned
|
||||
}
|
||||
end
|
||||
|
||||
def api_sort
|
||||
sort_params = params.permit(:content_id, :intended_position, :sortable_class)
|
||||
sortable_class = sort_params[:sortable_class].constantize # todo audit
|
||||
return unless sortable_class
|
||||
|
||||
content = sortable_class.find_by(id: sort_params[:content_id].to_i)
|
||||
return unless content.present?
|
||||
return unless content.user_id == current_user.id
|
||||
return unless content.respond_to?(:position)
|
||||
|
||||
# Ugh not another one of these backfills
|
||||
# todo remove this necessity
|
||||
if content.position.nil?
|
||||
if content.is_a?(AttributeCategory)
|
||||
content.backfill_categories_ordering!
|
||||
elsif content.is_a?(AttributeField)
|
||||
content.attribute_category.backfill_fields_ordering!
|
||||
else
|
||||
raise "Attempting to backfill ordering for a new class: #{content.class.name}"
|
||||
end
|
||||
end
|
||||
|
||||
content.reload
|
||||
if content.insert_at(1 + sort_params[:intended_position].to_i)
|
||||
render json: 200
|
||||
else
|
||||
render json: 500
|
||||
end
|
||||
end
|
||||
|
||||
# Content update for link-type fields
|
||||
def link_field_update
|
||||
@attribute_field = AttributeField.find_by(id: params[:field_id].to_i)
|
||||
attribute_value = @attribute_field.attribute_values.order('created_at desc').find_or_initialize_by(entity_params)
|
||||
attribute_value.user_id ||= current_user.id
|
||||
|
||||
if params.key?(:attribute_field)
|
||||
attribute_value.value = params.require(:attribute_field).fetch('linked_pages', [])
|
||||
else
|
||||
attribute_value.value = []
|
||||
end
|
||||
attribute_value.save!
|
||||
|
||||
# Make sure we create references from the entity to the linked pages
|
||||
set_entity
|
||||
referencing_page = @entity
|
||||
|
||||
# TODO: move this into a link mention update job
|
||||
valid_reference_ids = []
|
||||
referenced_page_codes = JSON.parse(attribute_value.value)
|
||||
referenced_page_codes.each do |page_code|
|
||||
page_type, page_id = page_code.split('-')
|
||||
|
||||
reference = referencing_page.outgoing_page_references.find_or_initialize_by(
|
||||
referenced_page_type: page_type,
|
||||
referenced_page_id: page_id,
|
||||
attribute_field_id: @attribute_field.id,
|
||||
reference_type: 'linked'
|
||||
)
|
||||
reference.cached_relation_title = @attribute_field.label
|
||||
reference.save!
|
||||
|
||||
valid_reference_ids << reference.reload.id
|
||||
end
|
||||
|
||||
# Delete all other references still attached to this field, but not present in this request
|
||||
referencing_page.outgoing_page_references
|
||||
.where(attribute_field_id: @attribute_field.id)
|
||||
.where.not(id: valid_reference_ids)
|
||||
.destroy_all
|
||||
end
|
||||
|
||||
# Content update for name fields
|
||||
def name_field_update
|
||||
@attribute_field = AttributeField.find_by(id: params[:field_id].to_i)
|
||||
attribute_value = @attribute_field.attribute_values.order('created_at desc').find_or_initialize_by(entity_params)
|
||||
attribute_value.value = field_params.fetch('value', '')
|
||||
attribute_value.user_id ||= current_user.id
|
||||
attribute_value.save!
|
||||
|
||||
# We also need to update the cached `name` field on the content page itself
|
||||
entity_type = entity_params.fetch(:entity_type)
|
||||
raise "Invalid entity type: #{entity_params.fetch(:entity_type)}" unless valid_content_types.include?(entity_params.fetch('entity_type'))
|
||||
entity = entity_type.constantize.find_by(id: entity_params.fetch(:entity_id).to_i)
|
||||
entity.update(name: field_params.fetch('value', ''))
|
||||
|
||||
render json: attribute_value.to_json, status: 200
|
||||
end
|
||||
|
||||
# Content update for text_area fields
|
||||
def text_field_update
|
||||
text = field_params.fetch('value', '')
|
||||
@attribute_field = AttributeField.find_by(id: params[:field_id].to_i)
|
||||
attribute_value = @attribute_field.attribute_values.order('created_at desc').find_or_initialize_by(entity_params)
|
||||
attribute_value.user_id ||= current_user.id
|
||||
attribute_value.value = text
|
||||
attribute_value.save!
|
||||
|
||||
UpdateTextAttributeReferencesJob.perform_later(attribute_value.id)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back(fallback_location: root_path, notice: "#{@attribute_field.label} updated!") }
|
||||
format.json { render json: attribute_value.to_json, status: 200 }
|
||||
end
|
||||
end
|
||||
|
||||
def tags_field_update
|
||||
return unless valid_content_types.include?(entity_params.fetch('entity_type'))
|
||||
|
||||
@attribute_field = AttributeField.find_by(id: params[:field_id].to_i)
|
||||
attribute_value = @attribute_field.attribute_values.order('created_at desc').find_or_initialize_by(entity_params)
|
||||
attribute_value.user_id ||= current_user.id
|
||||
attribute_value.value = field_params.fetch('value', '')
|
||||
attribute_value.save!
|
||||
|
||||
# Create the actual page_tag models too
|
||||
set_entity
|
||||
@content = @entity
|
||||
update_page_tags
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back(fallback_location: root_path, notice: "#{@attribute_field.label} updated!") }
|
||||
format.json { render json: attribute_value.to_json, status: 200 }
|
||||
end
|
||||
end
|
||||
|
||||
def universe_field_update
|
||||
return unless valid_content_types.include?(entity_params.fetch('entity_type'))
|
||||
|
||||
@attribute_field = AttributeField.find_by(id: params[:field_id].to_i)
|
||||
attribute_value = @attribute_field.attribute_values.order('created_at desc').find_or_initialize_by(entity_params)
|
||||
attribute_value.user_id ||= current_user.id
|
||||
|
||||
new_universe_id = field_params.fetch('value', nil).to_i
|
||||
new_universe_id = nil if new_universe_id == 0
|
||||
attribute_value.value = new_universe_id
|
||||
attribute_value.save!
|
||||
|
||||
@content = entity_params.fetch('entity_type').constantize.find_by(
|
||||
id: entity_params.fetch('entity_id'),
|
||||
user: current_user
|
||||
)
|
||||
@content.update!(universe_id: attribute_value.value)
|
||||
|
||||
render json: attribute_value.to_json, status: 200
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_content_type
|
||||
@content_type = params[:content_type]
|
||||
|
||||
def update_page_tags
|
||||
tag_list = field_params.fetch('value', '').split(PageTag::SUBMISSION_DELIMITER)
|
||||
current_tags = @content.page_tags.pluck(:tag)
|
||||
|
||||
tags_to_add = tag_list - current_tags
|
||||
tags_to_remove = current_tags - tag_list
|
||||
|
||||
tags_to_add.each do |tag|
|
||||
# TODO: create changelog event for AddedTag
|
||||
@content.page_tags.find_or_create_by(
|
||||
tag: tag,
|
||||
slug: PageTagService.slug_for(tag),
|
||||
user: @content.user
|
||||
)
|
||||
end
|
||||
|
||||
tags_to_remove.each do |tag|
|
||||
# TODO: create changelog event for RemovedTag or use destroy_all
|
||||
@content.page_tags.find_by(tag: tag).destroy
|
||||
end
|
||||
end
|
||||
|
||||
def render_json(content)
|
||||
render json: JSON.pretty_generate({
|
||||
name: content.try(:name),
|
||||
description: content.try(:description),
|
||||
universe: content.universe_id.nil? ? nil : {
|
||||
id: content.universe_id,
|
||||
name: content.universe.try(:name)
|
||||
},
|
||||
categories: Hash[content.class.attribute_categories(content.user).map { |category|
|
||||
[category.name, category.attribute_fields.map { |field|
|
||||
Hash[field.label, {
|
||||
id: field.name,
|
||||
value: field.attribute_values.find_by(
|
||||
entity_type: content.page_type,
|
||||
entity_id: content.id
|
||||
).try(:value) || ""
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
})
|
||||
end
|
||||
|
||||
# todo just do the migration for everyone so we can finally get rid of this
|
||||
def migrate_old_style_field_values
|
||||
content ||= content_type_from_controller(self.class).find_by(id: params[:id])
|
||||
TemporaryFieldMigrationService.migrate_fields_for_content(content, current_user) if content.present?
|
||||
end
|
||||
|
||||
def valid_content_types
|
||||
Rails.application.config.content_type_names[:all]
|
||||
end
|
||||
|
||||
def initialize_object
|
||||
content_type = content_type_from_controller(self.class)
|
||||
@content = content_type.new(content_params).tap do |c|
|
||||
c.user_id = current_user.id
|
||||
end
|
||||
end
|
||||
|
||||
def content_params
|
||||
content_class = content_type_from_controller(self.class)
|
||||
.name
|
||||
.downcase
|
||||
.to_sym
|
||||
|
||||
params.require(content_class).except(:page_tags, :_destroy).permit(content_param_list + [:deleted_at, :document_entity_id])
|
||||
end
|
||||
|
||||
def page_tag_params
|
||||
content_class = content_type_from_controller(self.class)
|
||||
.name
|
||||
.downcase
|
||||
.to_sym
|
||||
|
||||
params.require(content_class).permit(:page_tags)
|
||||
end
|
||||
|
||||
def entity_params
|
||||
params.require(:entity).permit(:entity_id, :entity_type)
|
||||
end
|
||||
|
||||
def field_params
|
||||
params.require(:field).permit(:name, :value)
|
||||
end
|
||||
|
||||
def content_deletion_redirect_url
|
||||
send("#{@content.class.name.underscore.pluralize}_path")
|
||||
end
|
||||
|
||||
def content_creation_redirect_url
|
||||
params[:redirect_override].presence || @content
|
||||
end
|
||||
|
||||
def content_symbol
|
||||
content_type_from_controller(self.class).to_s.downcase.to_sym
|
||||
end
|
||||
|
||||
def successful_response(url, notice)
|
||||
respond_to do |format|
|
||||
format.html {
|
||||
if params.key?(:override) && params[:override].key?(:redirect_path)
|
||||
redirect_to params[:override][:redirect_path], notice: notice
|
||||
else
|
||||
redirect_to url, notice: notice
|
||||
end
|
||||
}
|
||||
format.json { render json: @content || {}, status: :ok }
|
||||
end
|
||||
end
|
||||
|
||||
def failed_response(action, status, notice=nil)
|
||||
flash.now[:notice] = notice if notice
|
||||
respond_to do |format|
|
||||
format.html { render action: action, notice: notice }
|
||||
format.json { render json: @content.errors, status: status }
|
||||
end
|
||||
end
|
||||
|
||||
def humanized_model_name
|
||||
content_type_from_controller(self.class).model_name.human
|
||||
end
|
||||
|
||||
def set_attributes_content_type
|
||||
# Find the content type from the parameter
|
||||
@content_type = params[:content_type]
|
||||
# todo make this a before_action load_content_type
|
||||
unless valid_content_types.map(&:downcase).include?(@content_type)
|
||||
raise "Invalid content type on attributes customization page: #{@content_type}"
|
||||
end
|
||||
@content_type_class = @content_type.titleize.constantize
|
||||
end
|
||||
|
||||
def set_navbar_color
|
||||
content_type = @content_type_class || content_type_from_controller(self.class)
|
||||
@navbar_color = content_type.try(:hex_color) || '#2196F3'
|
||||
end
|
||||
|
||||
def set_entity
|
||||
entity_page_type = entity_params.fetch(:entity_type)
|
||||
entity_page_id = entity_params.fetch(:entity_id)
|
||||
|
||||
unless Rails.application.config.content_type_names[:all].include?(@content_type_class.name)
|
||||
raise "Invalid content type: #{@content_type}"
|
||||
return unless valid_content_types.include?(entity_page_type)
|
||||
@entity = entity_page_type.constantize.find_by(id: entity_page_id)
|
||||
end
|
||||
|
||||
# For showing a specific piece of content
|
||||
def set_navbar_actions
|
||||
content_type = @content_type_class || content_type_from_controller(self.class)
|
||||
@navbar_actions = []
|
||||
|
||||
return if [AttributeCategory, AttributeField].include?(content_type)
|
||||
|
||||
# Set up navbar actions for gallery specifically
|
||||
if action_name == 'gallery' && @content.present?
|
||||
# Add a link to view the content page
|
||||
@navbar_actions << {
|
||||
label: @content.name,
|
||||
href: polymorphic_path(@content)
|
||||
}
|
||||
|
||||
# Add a gallery title indicator
|
||||
@navbar_actions << {
|
||||
label: 'Gallery',
|
||||
href: send("gallery_#{@content.class.name.downcase}_path", @content)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def toggle_image_pin
|
||||
# Method stub for route
|
||||
end
|
||||
|
||||
def link_field_update
|
||||
# Method stub for route
|
||||
end
|
||||
|
||||
def name_field_update
|
||||
# Method stub for route
|
||||
end
|
||||
|
||||
def text_field_update
|
||||
# Method stub for route
|
||||
end
|
||||
|
||||
def tags_field_update
|
||||
# Method stub for route
|
||||
end
|
||||
|
||||
def universe_field_update
|
||||
# Method stub for route
|
||||
|
||||
def set_sidenav_expansion
|
||||
@sidenav_expansion = 'worldbuilding'
|
||||
end
|
||||
end
|
||||
@ -20,8 +20,13 @@ import 'controllers'
|
||||
import '../page_name_loader'
|
||||
|
||||
// Import Rails UJS for remote forms and CSRF tokens
|
||||
// Check if jQuery UJS is already loaded via asset pipeline before loading @rails/ujs
|
||||
import Rails from '@rails/ujs'
|
||||
Rails.start()
|
||||
if (typeof $ === 'undefined' || typeof $.rails === 'undefined') {
|
||||
Rails.start()
|
||||
} else {
|
||||
console.log('jQuery UJS already loaded via asset pipeline, skipping @rails/ujs')
|
||||
}
|
||||
|
||||
// Support component names relative to this directory:
|
||||
var componentRequireContext = require.context("components", true);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -11,34 +11,17 @@ module HasAttributes
|
||||
# Don't create any attribute categories for AttributeCategories or AttributeFields that share the ContentController
|
||||
return [] if ['attribute_category', 'attribute_field'].include?(content_name)
|
||||
|
||||
YAML.load_file(Rails.root.join('config', 'attributes', "#{content_name}.yml")).map do |category_name, defaults|
|
||||
# First, query for the category to see if it already exists
|
||||
category = user.attribute_categories.find_or_initialize_by(
|
||||
entity_type: self.content_name,
|
||||
name: category_name.to_s
|
||||
)
|
||||
creating_new_category = category.new_record?
|
||||
|
||||
# If the category didn't already exist, go ahead and set defaults on it and save
|
||||
if creating_new_category
|
||||
category.label = defaults[:label]
|
||||
category.icon = defaults[:icon]
|
||||
category.save!
|
||||
end
|
||||
|
||||
# If we created this category for the first time, we also want to make sure we create its default fields, too
|
||||
if creating_new_category && defaults.key?(:attributes)
|
||||
category.attribute_fields << defaults[:attributes].map do |field|
|
||||
af_field = category.attribute_fields.with_deleted.create!(
|
||||
old_column_source: field[:name],
|
||||
user: user,
|
||||
field_type: field[:field_type].presence || "text_area",
|
||||
label: field[:label].presence || 'Untitled field'
|
||||
)
|
||||
af_field
|
||||
end
|
||||
end
|
||||
end.compact
|
||||
# Use the new TemplateInitializationService for consistency
|
||||
template_service = TemplateInitializationService.new(user, content_name)
|
||||
|
||||
# Only create template if it doesn't already exist (for new users)
|
||||
if template_service.template_exists?
|
||||
# Return existing categories
|
||||
user.attribute_categories.where(entity_type: content_name).order(:position)
|
||||
else
|
||||
# Create new default template
|
||||
template_service.initialize_default_template!
|
||||
end
|
||||
end
|
||||
|
||||
def self.attribute_categories(user, show_hidden: false)
|
||||
|
||||
202
app/services/template_initialization_service.rb
Normal file
202
app/services/template_initialization_service.rb
Normal file
@ -0,0 +1,202 @@
|
||||
class TemplateInitializationService
|
||||
def initialize(user, content_type)
|
||||
@user = user
|
||||
@content_type = content_type.downcase
|
||||
@content_type_class = @content_type.titleize.constantize
|
||||
end
|
||||
|
||||
def initialize_default_template!
|
||||
# Load the YAML template structure
|
||||
yaml_path = Rails.root.join('config', 'attributes', "#{@content_type}.yml")
|
||||
unless File.exist?(yaml_path)
|
||||
Rails.logger.warn "No default template found for #{@content_type} at #{yaml_path}"
|
||||
return []
|
||||
end
|
||||
|
||||
template_structure = YAML.load_file(yaml_path) || {}
|
||||
created_categories = []
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
# Create categories in YAML order, acts_as_list will handle positioning
|
||||
template_structure.each do |category_name, details|
|
||||
# Create category (ignoring soft-deleted ones)
|
||||
category = create_category(category_name, details)
|
||||
created_categories << category
|
||||
|
||||
# Create fields for this category in YAML order
|
||||
if details[:attributes].present?
|
||||
details[:attributes].each do |field_details|
|
||||
create_field(category, field_details)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Clear any cached template data
|
||||
Rails.cache.delete("#{@content_type}_template_#{@user.id}")
|
||||
|
||||
# Force correct ordering based on YAML file structure
|
||||
if created_categories.any?
|
||||
created_categories.first.backfill_categories_ordering!
|
||||
end
|
||||
|
||||
created_categories
|
||||
end
|
||||
|
||||
def recreate_template_after_reset!
|
||||
# This method is specifically for template resets
|
||||
# It assumes existing data has been cleaned up and we need fresh defaults
|
||||
|
||||
Rails.logger.info "Recreating default template for user #{@user.id}, content_type #{@content_type}"
|
||||
result = initialize_default_template!
|
||||
|
||||
# Force reload of the content type's cached categories
|
||||
if @content_type_class.respond_to?(:clear_attribute_cache)
|
||||
@content_type_class.clear_attribute_cache(@user)
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def template_exists?
|
||||
# Check if user has any non-deleted template structure for this content type
|
||||
@user.attribute_categories
|
||||
.where(entity_type: @content_type)
|
||||
.exists?
|
||||
end
|
||||
|
||||
def default_template_structure
|
||||
# Load and return the YAML structure without creating database records
|
||||
yaml_path = Rails.root.join('config', 'attributes', "#{@content_type}.yml")
|
||||
return {} unless File.exist?(yaml_path)
|
||||
|
||||
YAML.load_file(yaml_path) || {}
|
||||
rescue => e
|
||||
Rails.logger.error "Error loading default template structure: #{e.message}"
|
||||
{}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_category(category_name, details)
|
||||
# Only look for non-deleted categories
|
||||
category = @user.attribute_categories
|
||||
.where(entity_type: @content_type, name: category_name.to_s)
|
||||
.first
|
||||
|
||||
if category.nil?
|
||||
# Let acts_as_list handle the positioning by creating at the end
|
||||
category = @user.attribute_categories.create!(
|
||||
entity_type: @content_type,
|
||||
name: category_name.to_s,
|
||||
label: details[:label] || category_name.to_s.titleize,
|
||||
icon: details[:icon] || 'help'
|
||||
)
|
||||
Rails.logger.debug "Created category: #{category.label} for #{@content_type} at position #{category.position}"
|
||||
else
|
||||
# Update existing category with current defaults (in case YAML changed)
|
||||
category.update!(
|
||||
label: details[:label] || category_name.to_s.titleize,
|
||||
icon: details[:icon] || 'help'
|
||||
)
|
||||
Rails.logger.debug "Updated existing category: #{category.label} for #{@content_type}"
|
||||
end
|
||||
|
||||
category
|
||||
end
|
||||
|
||||
def create_field(category, field_details)
|
||||
field_name = field_details[:name]
|
||||
field_type = field_details[:field_type].presence || "text_area"
|
||||
field_label = field_details[:label].presence || field_name.to_s.titleize
|
||||
|
||||
# Determine field_options for link fields
|
||||
field_options = {}
|
||||
if field_type == 'link'
|
||||
linkable_types = determine_linkable_types_for_field(field_name)
|
||||
field_options = { linkable_types: linkable_types } if linkable_types.any?
|
||||
Rails.logger.debug "Setting linkable_types for #{field_label}: #{linkable_types.inspect}"
|
||||
end
|
||||
|
||||
# Only look for non-deleted fields
|
||||
field = category.attribute_fields
|
||||
.where(old_column_source: field_name)
|
||||
.first
|
||||
|
||||
if field.nil?
|
||||
# Let acts_as_list handle positioning for fields too
|
||||
field = category.attribute_fields.create!(
|
||||
old_column_source: field_name,
|
||||
user: @user,
|
||||
field_type: field_type,
|
||||
label: field_label,
|
||||
field_options: field_options,
|
||||
migrated_from_legacy: true
|
||||
)
|
||||
Rails.logger.debug "Created field: #{field.label} in category #{category.label}"
|
||||
else
|
||||
# Update existing field with current defaults
|
||||
field.update!(
|
||||
field_type: field_type,
|
||||
label: field_label,
|
||||
field_options: field_options,
|
||||
migrated_from_legacy: true
|
||||
)
|
||||
Rails.logger.debug "Updated existing field: #{field.label} in category #{category.label}"
|
||||
end
|
||||
|
||||
field
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def determine_linkable_types_for_field(field_name)
|
||||
# Get the content class we're working with
|
||||
content_class = @content_type.classify.constantize
|
||||
|
||||
# Check if this field has a relationship defined
|
||||
content_relations = Rails.application.config.content_relations || {}
|
||||
content_type_key = @content_type.to_sym
|
||||
|
||||
if content_relations[content_type_key] && content_relations[content_type_key][field_name.to_sym]
|
||||
relation_info = content_relations[content_type_key][field_name.to_sym]
|
||||
|
||||
# Get the relationship model class
|
||||
relationship_class_name = relation_info[:relationship_class]
|
||||
if relationship_class_name
|
||||
begin
|
||||
relationship_class = relationship_class_name.constantize
|
||||
|
||||
# Find the belongs_to association that isn't the source
|
||||
source_association_name = relation_info[:source_key]&.to_s&.singularize
|
||||
|
||||
relationship_class.reflect_on_all_associations(:belongs_to).each do |assoc|
|
||||
# Skip the association back to the source model
|
||||
next if assoc.name.to_s == source_association_name
|
||||
|
||||
# This should be the target association
|
||||
target_class = assoc.klass
|
||||
return [target_class.name] if target_class
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.warn "Error determining linkable types for #{field_name}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Fallback: try to infer from common field naming patterns
|
||||
case field_name.to_s
|
||||
when /owner|character|person|friend|sibling|parent|child|relative/
|
||||
['Character']
|
||||
when /location|place|town|city|building/
|
||||
['Location']
|
||||
when /item|object|thing|equipment|weapon|tool/
|
||||
['Item']
|
||||
when /universe|world|setting/
|
||||
['Universe']
|
||||
else
|
||||
# Default to allowing links to all major content types
|
||||
['Character', 'Location', 'Item']
|
||||
end
|
||||
end
|
||||
end
|
||||
169
app/services/template_reset_service.rb
Normal file
169
app/services/template_reset_service.rb
Normal file
@ -0,0 +1,169 @@
|
||||
class TemplateResetService
|
||||
require 'set'
|
||||
|
||||
def initialize(user, content_type)
|
||||
@user = user
|
||||
@content_type = content_type.downcase
|
||||
@content_type_class = @content_type.titleize.constantize
|
||||
end
|
||||
|
||||
def reset_template!
|
||||
reset_summary = analyze_reset_impact
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
# Soft delete all existing categories and fields for this content type
|
||||
existing_categories = @user.attribute_categories
|
||||
.where(entity_type: @content_type)
|
||||
.includes(:attribute_fields)
|
||||
|
||||
# Store counts for summary
|
||||
reset_summary[:deleted_categories] = existing_categories.count
|
||||
reset_summary[:deleted_fields] = existing_categories.sum { |cat| cat.attribute_fields.count }
|
||||
|
||||
# Soft delete all fields first (to maintain referential integrity)
|
||||
existing_categories.each do |category|
|
||||
category.attribute_fields.destroy_all
|
||||
end
|
||||
|
||||
# Then soft delete all categories
|
||||
existing_categories.destroy_all
|
||||
|
||||
# Now recreate the default template structure
|
||||
template_service = TemplateInitializationService.new(@user, @content_type)
|
||||
created_categories = template_service.recreate_template_after_reset!
|
||||
|
||||
# Store creation counts for summary
|
||||
reset_summary[:created_categories] = created_categories.count
|
||||
reset_summary[:created_fields] = created_categories.sum { |cat| cat.attribute_fields.count }
|
||||
|
||||
Rails.logger.info "Template reset completed for user #{@user.id}, content_type #{@content_type}. " \
|
||||
"Deleted: #{reset_summary[:deleted_categories]} categories, #{reset_summary[:deleted_fields]} fields. " \
|
||||
"Created: #{reset_summary[:created_categories]} categories, #{reset_summary[:created_fields]} fields."
|
||||
end
|
||||
|
||||
reset_summary[:success] = true
|
||||
reset_summary[:message] = "Template has been reset to defaults successfully! " \
|
||||
"Removed #{reset_summary[:deleted_categories]} custom categories and #{reset_summary[:deleted_fields]} custom fields. " \
|
||||
"Recreated #{reset_summary[:created_categories]} default categories with #{reset_summary[:created_fields]} default fields."
|
||||
reset_summary
|
||||
rescue => e
|
||||
Rails.logger.error "Template reset failed for user #{@user.id}, content_type #{@content_type}: #{e.message}"
|
||||
reset_summary.merge({
|
||||
success: false,
|
||||
error: e.message,
|
||||
created_categories: 0,
|
||||
created_fields: 0
|
||||
})
|
||||
end
|
||||
|
||||
def analyze_reset_impact
|
||||
# Get current template structure to show what will be reset
|
||||
current_categories = @user.attribute_categories
|
||||
.where(entity_type: @content_type)
|
||||
.includes(attribute_fields: :attribute_values)
|
||||
.order(:position)
|
||||
|
||||
custom_categories = []
|
||||
modified_fields = []
|
||||
data_loss_warnings = []
|
||||
filled_attributes_count = 0
|
||||
affected_pages_count = 0
|
||||
affected_pages = Set.new
|
||||
|
||||
# Load default template to compare against
|
||||
default_structure = load_default_template_structure
|
||||
|
||||
current_categories.each do |category|
|
||||
category_info = {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
label: category.label,
|
||||
icon: category.icon,
|
||||
custom: !default_structure.key?(category.name.to_sym),
|
||||
hidden: category.hidden?,
|
||||
field_count: category.attribute_fields.count
|
||||
}
|
||||
|
||||
# Check if category is custom (not in defaults)
|
||||
if !default_structure.key?(category.name.to_sym)
|
||||
custom_categories << category_info
|
||||
end
|
||||
|
||||
# Check for modified fields and analyze attribute data
|
||||
category.attribute_fields.each do |field|
|
||||
# Count filled attribute values (non-empty, non-nil)
|
||||
filled_values = field.attribute_values.where(
|
||||
"value IS NOT NULL AND value != ''"
|
||||
)
|
||||
|
||||
filled_count = filled_values.count
|
||||
if filled_count > 0
|
||||
# Track unique pages that have data in this field
|
||||
filled_values.each do |attr|
|
||||
affected_pages.add("#{attr.entity_type}:#{attr.entity_id}")
|
||||
end
|
||||
|
||||
filled_attributes_count += filled_count
|
||||
|
||||
data_loss_warnings << {
|
||||
category: category.label,
|
||||
field: field.label,
|
||||
value_count: filled_count,
|
||||
filled_count: filled_count # For backward compatibility
|
||||
}
|
||||
end
|
||||
|
||||
# Check if field is custom or modified
|
||||
default_category = default_structure[category.name.to_sym]
|
||||
if default_category
|
||||
default_field = default_category[:attributes]&.find { |f| f[:name].to_s == field.name }
|
||||
if !default_field
|
||||
modified_fields << {
|
||||
category: category.label,
|
||||
field: field.label,
|
||||
type: 'custom_field'
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
affected_pages_count = affected_pages.size
|
||||
|
||||
# Load the default template to show what will be recreated
|
||||
template_service = TemplateInitializationService.new(@user, @content_type)
|
||||
default_structure = template_service.default_template_structure
|
||||
|
||||
default_categories_count = default_structure.count
|
||||
default_fields_count = default_structure.sum { |_, details| details[:attributes]&.count || 0 }
|
||||
|
||||
{
|
||||
total_categories: current_categories.count,
|
||||
total_fields: current_categories.sum { |cat| cat.attribute_fields.count },
|
||||
custom_categories: custom_categories,
|
||||
modified_fields: modified_fields,
|
||||
data_loss_warnings: data_loss_warnings,
|
||||
filled_attributes_count: filled_attributes_count,
|
||||
affected_pages_count: affected_pages_count,
|
||||
will_restore_defaults: !default_structure.empty?,
|
||||
default_categories_count: default_categories_count,
|
||||
default_fields_count: default_fields_count,
|
||||
default_structure: default_structure
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_default_template_structure
|
||||
# Load the default YAML structure
|
||||
yaml_path = Rails.root.join('config', 'attributes', "#{@content_type}.yml")
|
||||
if File.exist?(yaml_path)
|
||||
YAML.load_file(yaml_path) || {}
|
||||
else
|
||||
{}
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.warn "Could not load default template structure for #{@content_type}: #{e.message}"
|
||||
{}
|
||||
end
|
||||
end
|
||||
@ -76,8 +76,7 @@
|
||||
<!-- Category Body -->
|
||||
<div x-show="isOpen"
|
||||
x-transition
|
||||
class="p-4 category-body"
|
||||
@click.self="$dispatch('select-category', { id: <%= category.id %> })">
|
||||
class="p-4 category-body">
|
||||
|
||||
<!-- Fields Container -->
|
||||
<div class="fields-container space-y-2" data-category-id="<%= category.id %>">
|
||||
|
||||
@ -58,11 +58,6 @@
|
||||
<%= f.hidden_field :icon, id: 'attribute_category_icon' %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Display Order</label>
|
||||
<%= f.number_field :position, class: "shadow-sm block w-full sm:text-sm border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color}; border-color: #{content_type_class.hex_color};" %>
|
||||
<p class="mt-1 text-xs text-gray-500">Lower numbers appear first. You can also drag and drop to reorder.</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<%= f.submit 'Save Changes', class: "px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %>
|
||||
@ -74,6 +69,25 @@
|
||||
<!-- Advanced Tab -->
|
||||
<div x-show="activeTab === 'advanced'" class="py-4 space-y-4">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h6 class="text-sm font-medium text-gray-700 mb-2">Display Order</h6>
|
||||
<div class="bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Categories are displayed in order on content pages. Lower numbers appear first.
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
<strong>Tip:</strong> Use drag & drop in the template editor for easier reordering. This manual control is for fine-tuning.
|
||||
</p>
|
||||
<%= form_for(category, url: "/plan/attribute_categories/#{category.id}", method: :put, html: {class: 'space-y-3', 'data-type': 'json'}, remote: true) do |f| %>
|
||||
<div class="flex items-center space-x-3">
|
||||
<label class="text-sm font-medium text-gray-700">Position:</label>
|
||||
<%= f.number_field :position, class: "w-20 shadow-sm border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color}; border-color: #{content_type_class.hex_color};" %>
|
||||
<%= f.submit 'Update', class: "px-3 py-1.5 border border-transparent rounded-md shadow-sm text-xs font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h6 class="text-sm font-medium text-gray-700 mb-2">Category Visibility</h6>
|
||||
<div class="bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||
|
||||
@ -68,11 +68,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Display Order</label>
|
||||
<%= f.number_field :position, class: "shadow-sm block w-full sm:text-sm border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color}; border-color: #{content_type_class.hex_color};" %>
|
||||
<p class="mt-1 text-xs text-gray-500">Lower numbers appear first. You can also drag and drop to reorder.</p>
|
||||
</div>
|
||||
<% if field.field_type == 'link' %>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700">Linkable Types</label>
|
||||
<div class="flex space-x-2">
|
||||
<button type="button"
|
||||
onclick="selectAllLinkableTypes()"
|
||||
class="px-2 py-1 text-xs font-medium text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded transition-colors">
|
||||
Select All
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="selectNoneLinkableTypes()"
|
||||
class="px-2 py-1 text-xs font-medium text-gray-600 hover:text-gray-700 hover:bg-gray-50 rounded transition-colors">
|
||||
Select None
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mb-4">Select which content types users can link to from this field.</p>
|
||||
|
||||
<!-- Hidden field to ensure linkable_types parameter is always sent -->
|
||||
<input type="hidden" name="attribute_field[field_options][linkable_types][]" value="" />
|
||||
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto border border-gray-200 rounded-md p-3 bg-gray-50">
|
||||
<% Rails.application.config.content_types[:all].each do |content_type| %>
|
||||
<label class="flex items-center p-2 bg-white rounded-md border border-gray-200 hover:border-gray-300 hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<input type="checkbox"
|
||||
name="attribute_field[field_options][linkable_types][]"
|
||||
value="<%= content_type.name %>"
|
||||
<%= 'checked' if field.field_options&.dig('linkable_types')&.include?(content_type.name) %>
|
||||
class="h-4 w-4 border-gray-300 rounded focus:ring-2 focus:ring-offset-2 mr-3"
|
||||
style="color: <%= content_type_class.hex_color %>; --tw-ring-color: <%= content_type_class.hex_color %>;">
|
||||
|
||||
<div class="flex items-center flex-grow">
|
||||
<div class="w-6 h-6 rounded-full flex items-center justify-center mr-2"
|
||||
style="background-color: <%= content_type.hex_color %>20; color: <%= content_type.hex_color %>;">
|
||||
<i class="material-icons text-xs"><%= content_type.icon %></i>
|
||||
</div>
|
||||
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
<%= content_type.name.pluralize %>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="pt-2">
|
||||
<%= f.submit 'Save Changes', class: "px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %>
|
||||
@ -84,95 +126,146 @@
|
||||
<div x-show="activeTab === 'appearance'" class="py-4 space-y-4">
|
||||
<div class="space-y-4">
|
||||
<% if field.field_type == 'text_area' %>
|
||||
<div x-data="{ selectedSize: 'medium' }">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Input Size</label>
|
||||
<select x-model="selectedSize" class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 sm:text-sm rounded-md" style="--tw-ring-color: <%= content_type_class.hex_color %>; border-color: <%= content_type_class.hex_color %>;">
|
||||
<option value="small">Small (one line)</option>
|
||||
<option value="medium">Medium (multiple lines)</option>
|
||||
<option value="large">Large (rich text)</option>
|
||||
</select>
|
||||
|
||||
<!-- Preview Section -->
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Preview</label>
|
||||
<div class="border border-gray-200 rounded-md p-3 bg-gray-50">
|
||||
<!-- Small input preview -->
|
||||
<input x-show="selectedSize === 'small'"
|
||||
type="text"
|
||||
placeholder="This is how the small input will look..."
|
||||
disabled
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white text-gray-500 cursor-not-allowed">
|
||||
|
||||
<!-- Medium textarea preview -->
|
||||
<textarea x-show="selectedSize === 'medium'"
|
||||
placeholder="This is how the medium textarea will look... It spans multiple lines Perfect for longer descriptions"
|
||||
disabled
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white text-gray-500 cursor-not-allowed resize-none"></textarea>
|
||||
|
||||
<!-- Large textarea preview -->
|
||||
<textarea x-show="selectedSize === 'large'"
|
||||
placeholder="This is how the large textarea will look... It has more vertical space Ideal for rich text content Longer descriptions Multiple paragraphs And more detailed information"
|
||||
disabled
|
||||
rows="6"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white text-gray-500 cursor-not-allowed resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% elsif field.field_type == 'link' %>
|
||||
<%= form_for(field, method: :put, html: {class: 'space-y-4', 'data-type': 'json', id: 'linkable-types-form'}, remote: true) do |f| %>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700">Linkable Types</label>
|
||||
<div class="flex space-x-2">
|
||||
<button type="button"
|
||||
onclick="selectAllLinkableTypes()"
|
||||
class="px-2 py-1 text-xs font-medium text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded transition-colors">
|
||||
Select All
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="selectNoneLinkableTypes()"
|
||||
class="px-2 py-1 text-xs font-medium text-gray-600 hover:text-gray-700 hover:bg-gray-50 rounded transition-colors">
|
||||
Select None
|
||||
</button>
|
||||
<%= form_for(field, method: :put, html: {class: 'space-y-4', 'data-type': 'json'}, remote: true) do |f| %>
|
||||
<div x-data="{ selectedSize: '<%= field.field_options&.dig('input_size') || 'medium' %>' }">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Input Size</label>
|
||||
<select x-model="selectedSize"
|
||||
name="attribute_field[field_options][input_size]"
|
||||
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 sm:text-sm rounded-md"
|
||||
style="--tw-ring-color: <%= content_type_class.hex_color %>; border-color: <%= content_type_class.hex_color %>;">
|
||||
<option value="small">Small (one line)</option>
|
||||
<option value="medium">Medium (multiple lines)</option>
|
||||
<option value="large">Large (rich text)</option>
|
||||
</select>
|
||||
|
||||
<!-- Preview Section -->
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Preview</label>
|
||||
<div class="border border-gray-200 rounded-md p-3 bg-gray-50">
|
||||
<!-- Small input preview -->
|
||||
<input x-show="selectedSize === 'small'"
|
||||
type="text"
|
||||
placeholder="This is how the small input will look..."
|
||||
disabled
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white text-gray-500 cursor-not-allowed">
|
||||
|
||||
<!-- Medium textarea preview -->
|
||||
<textarea x-show="selectedSize === 'medium'"
|
||||
placeholder="This is how the medium textarea will look... It spans multiple lines Perfect for longer descriptions"
|
||||
disabled
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white text-gray-500 cursor-not-allowed resize-none"></textarea>
|
||||
|
||||
<!-- Large textarea preview -->
|
||||
<textarea x-show="selectedSize === 'large'"
|
||||
placeholder="This is how the large textarea will look... It has more vertical space Ideal for rich text content Longer descriptions Multiple paragraphs And more detailed information"
|
||||
disabled
|
||||
rows="6"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white text-gray-500 cursor-not-allowed resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mb-4">Select which content types users can link to from this field.</p>
|
||||
|
||||
<!-- Hidden field to ensure linkable_types parameter is always sent -->
|
||||
<input type="hidden" name="attribute_field[field_options][linkable_types][]" value="" />
|
||||
|
||||
<div class="space-y-2 max-h-64 overflow-y-auto border border-gray-200 rounded-md p-3 bg-gray-50">
|
||||
<% Rails.application.config.content_types[:all].each do |content_type| %>
|
||||
<label class="flex items-center p-3 bg-white rounded-md border border-gray-200 hover:border-gray-300 hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<input type="checkbox"
|
||||
name="attribute_field[field_options][linkable_types][]"
|
||||
value="<%= content_type.name %>"
|
||||
<%= 'checked' if field.field_options&.dig('linkable_types')&.include?(content_type.name) %>
|
||||
class="h-4 w-4 border-gray-300 rounded focus:ring-2 focus:ring-offset-2 mr-3"
|
||||
style="color: <%= content_type_class.hex_color %>; --tw-ring-color: <%= content_type_class.hex_color %>;">
|
||||
|
||||
<div class="flex items-center flex-grow">
|
||||
<div class="w-8 h-8 rounded-full flex items-center justify-center mr-3"
|
||||
style="background-color: <%= content_type.hex_color %>20; color: <%= content_type.hex_color %>;">
|
||||
<i class="material-icons text-sm"><%= content_type.icon %></i>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow">
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
<%= content_type.name.pluralize %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<%= f.submit 'Save Linkable Types', class: "px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %>
|
||||
<%= f.submit 'Save Changes', class: "px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% elsif field.field_type == 'link' %>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Choose how linked content appears on your pages.
|
||||
</p>
|
||||
|
||||
<%= form_for(field, method: :put, html: {class: 'space-y-4', 'data-type': 'json'}, remote: true) do |f| %>
|
||||
<div x-data="{ displayStyle: '<%= field.field_options&.dig('display_style') || 'list' %>' }">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">Display Style</label>
|
||||
|
||||
<!-- Display Style Options -->
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-start p-3 border rounded-md cursor-pointer hover:bg-gray-50"
|
||||
:class="displayStyle === 'list' ? 'border-blue-300 bg-blue-50' : 'border-gray-200'">
|
||||
<input type="radio"
|
||||
x-model="displayStyle"
|
||||
name="attribute_field[field_options][display_style]"
|
||||
value="list"
|
||||
class="mt-1 h-4 w-4 border-gray-300 focus:ring-2 focus:ring-offset-2"
|
||||
style="color: <%= content_type_class.hex_color %>; --tw-ring-color: <%= content_type_class.hex_color %>;">
|
||||
<div class="ml-3">
|
||||
<div class="text-sm font-medium text-gray-900">Simple List</div>
|
||||
<div class="text-xs text-gray-500">Character Name • Location Name • Item Name</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start p-3 border rounded-md cursor-pointer hover:bg-gray-50"
|
||||
:class="displayStyle === 'cards' ? 'border-blue-300 bg-blue-50' : 'border-gray-200'">
|
||||
<input type="radio"
|
||||
x-model="displayStyle"
|
||||
name="attribute_field[field_options][display_style]"
|
||||
value="cards"
|
||||
class="mt-1 h-4 w-4 border-gray-300 focus:ring-2 focus:ring-offset-2"
|
||||
style="color: <%= content_type_class.hex_color %>; --tw-ring-color: <%= content_type_class.hex_color %>;">
|
||||
<div class="ml-3">
|
||||
<div class="text-sm font-medium text-gray-900">Cards with Icons</div>
|
||||
<div class="text-xs text-gray-500">📝 Character Name 🏠 Location Name ⚔️ Item Name</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start p-3 border rounded-md cursor-pointer hover:bg-gray-50"
|
||||
:class="displayStyle === 'compact' ? 'border-blue-300 bg-blue-50' : 'border-gray-200'">
|
||||
<input type="radio"
|
||||
x-model="displayStyle"
|
||||
name="attribute_field[field_options][display_style]"
|
||||
value="compact"
|
||||
class="mt-1 h-4 w-4 border-gray-300 focus:ring-2 focus:ring-offset-2"
|
||||
style="color: <%= content_type_class.hex_color %>; --tw-ring-color: <%= content_type_class.hex_color %>;">
|
||||
<div class="ml-3">
|
||||
<div class="text-sm font-medium text-gray-900">Compact Tags</div>
|
||||
<div class="text-xs text-gray-500">[Character Name] [Location Name] [Item Name]</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Preview (placeholder) -->
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Preview</label>
|
||||
<div class="border border-gray-200 rounded-md p-3 bg-gray-50">
|
||||
<div x-show="displayStyle === 'list'" class="text-sm text-gray-600">
|
||||
<div>• Alice Johnson</div>
|
||||
<div>• The Rusty Tavern</div>
|
||||
<div>• Magic Sword</div>
|
||||
</div>
|
||||
<div x-show="displayStyle === 'cards'" class="text-sm text-gray-600">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center px-2 py-1 bg-white rounded border">📝 Alice Johnson</span>
|
||||
<span class="inline-flex items-center px-2 py-1 bg-white rounded border">🏠 The Rusty Tavern</span>
|
||||
<span class="inline-flex items-center px-2 py-1 bg-white rounded border">⚔️ Magic Sword</span>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="displayStyle === 'compact'" class="text-sm text-gray-600">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span class="px-2 py-1 bg-gray-200 rounded text-xs">Alice Johnson</span>
|
||||
<span class="px-2 py-1 bg-gray-200 rounded text-xs">The Rusty Tavern</span>
|
||||
<span class="px-2 py-1 bg-gray-200 rounded text-xs">Magic Sword</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<%= f.submit 'Save Changes', class: "px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-8">
|
||||
<div class="text-gray-400 mb-2">
|
||||
<i class="material-icons text-3xl">palette</i>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">
|
||||
No appearance options available for this field type.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@ -180,9 +273,34 @@
|
||||
<!-- Advanced Tab -->
|
||||
<div x-show="activeTab === 'advanced'" class="py-4 space-y-4">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h6 class="text-sm font-medium text-gray-700 mb-2">Display Order</h6>
|
||||
<div class="bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Fields are displayed in order within their category. Lower numbers appear first.
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
<strong>Tip:</strong> Use drag & drop in the template editor for easier reordering. This manual control is for fine-tuning.
|
||||
</p>
|
||||
<%= form_for(field, method: :put, html: {class: 'space-y-3', 'data-type': 'json'}, remote: true) do |f| %>
|
||||
<div class="flex items-center space-x-3">
|
||||
<label class="text-sm font-medium text-gray-700">Position:</label>
|
||||
<%= f.number_field :position, class: "w-20 shadow-sm border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color}; border-color: #{content_type_class.hex_color};" %>
|
||||
<%= f.submit 'Update', class: "px-3 py-1.5 border border-transparent rounded-md shadow-sm text-xs font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h6 class="text-sm font-medium text-gray-700 mb-2">Field Visibility</h6>
|
||||
<div class="bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||
<div class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<div class="text-xs text-blue-800 space-y-1">
|
||||
<div><strong>Hidden fields:</strong> Completely removed from content pages - not shown or used at all</div>
|
||||
<div><strong>Private fields:</strong> Only visible to you, even when your content page is public</div>
|
||||
</div>
|
||||
</div>
|
||||
<%= form_for(field, method: :put, html: {class: 'inline', 'data-type': 'json'}, remote: true) do |f| %>
|
||||
<%= f.hidden_field :hidden, value: !field.hidden %>
|
||||
<% if field.hidden? %>
|
||||
|
||||
@ -58,18 +58,8 @@
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<!-- Configure Button -->
|
||||
<button class="p-1.5 rounded hover:bg-gray-100"
|
||||
title="Configure Field"
|
||||
@click.stop="console.log('Cog clicked for field:', <%= field.id %>); $dispatch('select-field', { id: <%= field.id %> })">
|
||||
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Visibility Button -->
|
||||
<% unless field.name_field? || field.universe_field? || field.tags_field? %>
|
||||
<% unless field.name_field? %>
|
||||
<button class="field-visibility-toggle p-1.5 rounded hover:bg-gray-100 transition-colors"
|
||||
data-field-id="<%= field.id %>"
|
||||
data-hidden="<%= field.hidden? %>"
|
||||
@ -78,22 +68,14 @@
|
||||
<svg class="w-4 h-4 text-gray-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<% if field.hidden? %>
|
||||
<!-- Hidden state - show EYE CLOSED icon -->
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 711.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
|
||||
<% else %>
|
||||
<!-- Visible state - show EYE OPEN icon -->
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 616 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||
<% end %>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Delete Button -->
|
||||
<button class="p-1.5 rounded hover:bg-gray-100 hover:text-red-500"
|
||||
title="Delete Field">
|
||||
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@ -89,16 +89,107 @@
|
||||
<i class="material-icons text-gray-400">chevron_right</i>
|
||||
</button>
|
||||
|
||||
<button class="w-full flex items-center justify-between p-3 border border-red-200 rounded-lg hover:bg-red-50 transition-colors text-left">
|
||||
<div class="flex items-center">
|
||||
<i class="material-icons text-red-600 mr-3">restore</i>
|
||||
<div>
|
||||
<div class="font-medium text-red-900">Reset Template</div>
|
||||
<div class="text-sm text-red-600">Delete all customizations and start fresh</div>
|
||||
<div class="border border-red-200 rounded-lg overflow-hidden"
|
||||
x-data="templateResetComponent()"
|
||||
id="reset-template-component">
|
||||
<button @click="toggleReset()"
|
||||
class="w-full flex items-center justify-between p-3 hover:bg-red-50 transition-colors text-left">
|
||||
<div class="flex items-center">
|
||||
<i class="material-icons text-red-600 mr-3">restore</i>
|
||||
<div>
|
||||
<div class="font-medium text-red-900">Reset Template</div>
|
||||
<div class="text-sm text-red-600">Delete all customizations and start fresh</div>
|
||||
</div>
|
||||
</div>
|
||||
<i class="material-icons text-red-400 transition-transform"
|
||||
:class="{ 'rotate-180': resetOpen }">expand_more</i>
|
||||
</button>
|
||||
|
||||
<div x-show="resetOpen"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 transform scale-100"
|
||||
x-transition:leave-end="opacity-0 transform scale-95"
|
||||
class="border-t border-red-200 bg-red-50 p-4">
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="text-center py-4">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-red-600 mx-auto mb-2"></div>
|
||||
<p class="text-sm text-red-700">Analyzing template...</p>
|
||||
</div>
|
||||
|
||||
<!-- Reset Analysis -->
|
||||
<div x-show="!loading && resetAnalysis && !resetConfirm" class="space-y-4">
|
||||
<div class="bg-white rounded-md p-3 border border-red-200">
|
||||
<h4 class="font-medium text-red-900 mb-2 flex items-center">
|
||||
<i class="material-icons text-red-600 mr-2">warning</i>
|
||||
Reset Impact Analysis
|
||||
</h4>
|
||||
<div class="text-sm text-red-700 space-y-1">
|
||||
<p><strong x-text="resetAnalysis?.total_categories || 0"></strong> categories and <strong x-text="resetAnalysis?.total_fields || 0"></strong> fields will be deleted</p>
|
||||
<p x-show="resetAnalysis?.custom_categories?.length > 0"><strong x-text="resetAnalysis?.custom_categories?.length || 0"></strong> custom categories you created</p>
|
||||
<p x-show="resetAnalysis?.data_loss_warnings?.length > 0" class="text-red-800 font-medium">
|
||||
<strong x-text="resetAnalysis?.data_loss_warnings?.length || 0"></strong> fields have data that will be permanently lost!
|
||||
</p>
|
||||
<p x-show="resetAnalysis?.filled_attributes_count > 0" class="text-red-900 font-bold">
|
||||
<strong x-text="resetAnalysis?.filled_attributes_count || 0"></strong> filled answers will be deleted across <strong x-text="resetAnalysis?.affected_pages_count || 0"></strong> different pages
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Loss Warnings -->
|
||||
<div x-show="resetAnalysis?.data_loss_warnings?.length > 0" class="bg-red-100 rounded-md p-3 border border-red-300">
|
||||
<h5 class="font-medium text-red-900 mb-2">⚠️ Data Loss Warning</h5>
|
||||
<div class="text-sm text-red-800 space-y-1 max-h-32 overflow-y-auto">
|
||||
<template x-for="warning in (resetAnalysis?.data_loss_warnings || [])" :key="warning.category + warning.field">
|
||||
<p><strong x-text="warning.category"></strong> → <span x-text="warning.field"></span> (<span x-text="warning.filled_count"></span> filled answers)</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-2">
|
||||
<button @click="resetOpen = false"
|
||||
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="resetConfirm = true"
|
||||
class="px-3 py-1.5 bg-red-600 border border-red-600 rounded-md text-sm font-medium text-white hover:bg-red-700">
|
||||
I Understand, Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Final Confirmation -->
|
||||
<div x-show="resetConfirm" class="space-y-4">
|
||||
<div class="bg-red-100 rounded-md p-3 border border-red-300">
|
||||
<h4 class="font-medium text-red-900 mb-2">⚠️ Final Confirmation Required</h4>
|
||||
<p class="text-sm text-red-800 mb-3">
|
||||
Type "<strong><%= content_type.titleize %></strong>" below to confirm you want to permanently reset this template:
|
||||
</p>
|
||||
<input type="text"
|
||||
x-model="confirmText"
|
||||
placeholder="<%= content_type.titleize %>"
|
||||
class="w-full px-3 py-2 border border-red-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-red-500">
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-2">
|
||||
<button @click="resetConfirm = false"
|
||||
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="performReset()"
|
||||
:disabled="confirmText !== '<%= content_type.titleize %>'"
|
||||
:class="confirmText === '<%= content_type.titleize %>' ? 'bg-red-600 hover:bg-red-700 text-white' : 'bg-gray-300 text-gray-500 cursor-not-allowed'"
|
||||
class="px-3 py-1.5 border border-transparent rounded-md text-sm font-medium focus:outline-none focus:ring-2 focus:ring-red-500">
|
||||
<i class="material-icons text-sm mr-1">delete_forever</i>
|
||||
Reset Template Permanently
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<i class="material-icons text-red-400">chevron_right</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -338,6 +338,7 @@ Rails.application.routes.draw do
|
||||
get ':content_type/attributes', to: 'content#attributes', as: :attribute_customization
|
||||
get ':content_type/attributes-tailwind', to: 'content#attributes_tailwind', as: :attribute_customization_tailwind
|
||||
get ':content_type/template/export', to: 'content#export_template', as: :export_template
|
||||
delete ':content_type/template/reset', to: 'content#reset_template', as: :reset_template
|
||||
end
|
||||
|
||||
# For non-API API endpoints
|
||||
@ -347,6 +348,8 @@ Rails.application.routes.draw do
|
||||
patch '/text_field_update/:field_id', to: 'content#text_field_update', as: :text_field_update
|
||||
patch '/tags_field_update/:field_id', to: 'content#tags_field_update', as: :tags_field_update
|
||||
patch '/universe_field_update/:field_id', to: 'content#universe_field_update', as: :universe_field_update
|
||||
patch '/sort/categories', to: 'attribute_categories#sort', as: :sort_categories_internal
|
||||
patch '/sort/fields', to: 'attribute_fields#sort', as: :sort_fields_internal
|
||||
end
|
||||
|
||||
get 'search/', to: 'search#results'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user