template editor tweaks from claudy

This commit is contained in:
Andrew Brown 2025-07-03 16:55:33 -07:00
parent df56671ed3
commit f2811388d7
15 changed files with 2717 additions and 1561 deletions

View File

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

View File

@ -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, '')

View File

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

View File

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

View File

@ -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)

View 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

View 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

View File

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

View File

@ -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">

View File

@ -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...&#10;It spans multiple lines&#10;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...&#10;It has more vertical space&#10;Ideal for rich text content&#10;Longer descriptions&#10;Multiple paragraphs&#10;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...&#10;It spans multiple lines&#10;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...&#10;It has more vertical space&#10;Ideal for rich text content&#10;Longer descriptions&#10;Multiple paragraphs&#10;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? %>

View File

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

View File

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

View File

@ -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'