diff --git a/app/controllers/attribute_categories_controller.rb b/app/controllers/attribute_categories_controller.rb index 5ee1e83f..bb98bb67 100644 --- a/app/controllers/attribute_categories_controller.rb +++ b/app/controllers/attribute_categories_controller.rb @@ -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 diff --git a/app/controllers/attribute_fields_controller.rb b/app/controllers/attribute_fields_controller.rb index 8cb1dc51..a382ef4c 100644 --- a/app/controllers/attribute_fields_controller.rb +++ b/app/controllers/attribute_fields_controller.rb @@ -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, '') diff --git a/app/controllers/content_controller.rb b/app/controllers/content_controller.rb index 3e821472..9881139e 100644 --- a/app/controllers/content_controller.rb +++ b/app/controllers/content_controller.rb @@ -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.", + "Get more" + ].map { |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 \ No newline at end of file diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 8191b852..11152aea 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -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); diff --git a/app/javascript/packs/template_editor.js b/app/javascript/packs/template_editor.js index 73f31b14..2226ed91 100644 --- a/app/javascript/packs/template_editor.js +++ b/app/javascript/packs/template_editor.js @@ -1,55 +1,176 @@ // Template Editor JavaScript -document.addEventListener('DOMContentLoaded', function() { - // Initialize Alpine component data - window.initTemplateEditor = function() { - return { - selectedCategory: null, - selectedField: null, - configuring: false, - activePanel: window.innerWidth >= 768 ? 'both' : 'template', - - // Category selection - selectCategory(categoryId) { - this.selectedCategory = categoryId; - this.selectedField = null; - this.configuring = true; - - if (window.innerWidth < 768) { - this.activePanel = 'config'; - } - - // Load category configuration via AJAX - fetch(`/plan/attribute_categories/${categoryId}/edit`) - .then(response => response.text()) - .then(html => { - document.getElementById('category-config-container').innerHTML = html; - }); - }, - - // Field selection - selectField(fieldId) { - this.selectedField = fieldId; - this.selectedCategory = null; - this.configuring = true; - - if (window.innerWidth < 768) { - this.activePanel = 'config'; - } - - // Load field configuration via AJAX - fetch(`/plan/attribute_fields/${fieldId}/edit`) - .then(response => response.text()) - .then(html => { - document.getElementById('field-config-container').innerHTML = html; - }); +// Template Reset Component function for Alpine.js (define before DOM ready) +window.templateResetComponent = function() { + return { + resetOpen: false, + resetConfirm: false, + resetAnalysis: null, + loading: false, + confirmText: '', + + toggleReset() { + this.resetOpen = !this.resetOpen; + if (this.resetOpen && !this.resetAnalysis) { + this.fetchAnalysis(); } - }; + }, + + fetchAnalysis() { + console.log('Fetching reset analysis...'); + this.loading = true; + const contentType = document.querySelector('.attributes-editor').dataset.contentType; + + fetch(`/plan/${contentType}/template/reset`, { + method: 'DELETE', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }) + .then(response => response.json()) + .then(data => { + console.log('Analysis data:', data); + this.resetAnalysis = data; + this.loading = false; + }) + .catch(error => { + console.error('Error:', error); + this.loading = false; + if (typeof showNotification === 'function') { + showNotification('Failed to analyze template reset impact', 'error'); + } else { + alert('Failed to analyze template reset impact'); + } + }); + }, + + performReset() { + console.log('Performing reset...'); + this.loading = true; + const contentType = document.querySelector('.attributes-editor').dataset.contentType; + + fetch(`/plan/${contentType}/template/reset?confirm=true`, { + method: 'DELETE', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + if (typeof showNotification === 'function') { + showNotification(data.message, 'success'); + } else { + alert(data.message); + } + setTimeout(() => window.location.reload(), 2000); + } else { + if (typeof showNotification === 'function') { + showNotification(data.error || 'Failed to reset template', 'error'); + } else { + alert(data.error || 'Failed to reset template'); + } + this.loading = false; + } + }) + .catch(error => { + console.error('Error:', error); + if (typeof showNotification === 'function') { + showNotification('Failed to reset template', 'error'); + } else { + alert('Failed to reset template'); + } + this.loading = false; + }); + } }; +}; + +// Initialize Alpine component data (global function) +window.initTemplateEditor = function() { + return { + selectedCategory: null, + selectedField: null, + configuring: false, + activePanel: window.innerWidth >= 768 ? 'both' : 'template', + + // Initialize reset analysis as empty object to prevent Alpine errors + resetAnalysis: null, + + // Category selection + selectCategory(categoryId) { + this.selectedCategory = categoryId; + this.selectedField = null; + this.configuring = true; + + if (window.innerWidth < 768) { + this.activePanel = 'config'; + } + + // Show loading animation + showConfigLoadingAnimation('category-config-container'); + + // Load category configuration via AJAX + fetch(`/plan/attribute_categories/${categoryId}/edit`) + .then(response => response.text()) + .then(html => { + document.getElementById('category-config-container').innerHTML = html; + // Bind remote form handlers to newly loaded forms + bindRemoteFormsInContainer('category-config-container'); + // Hide loading animation + hideConfigLoadingAnimation('category-config-container'); + }) + .catch(error => { + console.error('Error loading category config:', error); + hideConfigLoadingAnimation('category-config-container'); + showNotification('Failed to load category configuration', 'error'); + }); + }, + + // Field selection + selectField(fieldId) { + this.selectedField = fieldId; + this.selectedCategory = null; + this.configuring = true; + + if (window.innerWidth < 768) { + this.activePanel = 'config'; + } + + // Show loading animation + showConfigLoadingAnimation('field-config-container'); + + // Load field configuration via AJAX + fetch(`/plan/attribute_fields/${fieldId}/edit`) + .then(response => response.text()) + .then(html => { + document.getElementById('field-config-container').innerHTML = html; + // Bind remote form handlers to newly loaded forms + bindRemoteFormsInContainer('field-config-container'); + // Hide loading animation + hideConfigLoadingAnimation('field-config-container'); + }) + .catch(error => { + console.error('Error loading field config:', error); + hideConfigLoadingAnimation('field-config-container'); + showNotification('Failed to load field configuration', 'error'); + }); + } + }; +}; + +document.addEventListener('DOMContentLoaded', function() { // Initialize sortable for categories initSortables(); + // Handle remote form submissions for dynamically loaded forms + setupRemoteFormHandlers(); + // Show category form document.addEventListener('click', function(event) { if (event.target.closest('[data-action="click->attributes-editor#showAddCategoryForm"]')) { @@ -60,14 +181,24 @@ document.addEventListener('DOMContentLoaded', function() { // Handle select-category event dispatched from category cards document.addEventListener('select-category', function(event) { - const alpine = Alpine.getRoot(document.querySelector('.attributes-editor')); - alpine.$data.selectCategory(event.detail.id); + const alpineElement = document.querySelector('.attributes-editor'); + if (alpineElement && alpineElement._x_dataStack) { + const alpine = alpineElement._x_dataStack[0]; + if (alpine.selectCategory) { + alpine.selectCategory(event.detail.id); + } + } }); // Handle select-field event dispatched from field items document.addEventListener('select-field', function(event) { - const alpine = Alpine.getRoot(document.querySelector('.attributes-editor')); - alpine.$data.selectField(event.detail.id); + const alpineElement = document.querySelector('.attributes-editor'); + if (alpineElement && alpineElement._x_dataStack) { + const alpine = alpineElement._x_dataStack[0]; + if (alpine.selectField) { + alpine.selectField(event.detail.id); + } + } }); // Category suggestions @@ -77,7 +208,10 @@ document.addEventListener('DOMContentLoaded', function() { const contentType = document.querySelector('.attributes-editor').dataset.contentType; const resultContainer = this.closest('div').querySelector('.suggest-categories-container'); - fetch(`/api/v1/categories/suggest/${contentType}`) + // Show loading animation in the suggestions container + showSuggestionsLoadingAnimation(resultContainer); + + fetch(`/plan/attribute_categories/suggest?content_type=${contentType}`) .then(response => response.json()) .then(data => { const existingCategories = Array.from(document.querySelectorAll('.category-label')).map(el => el.textContent.trim()); @@ -97,6 +231,10 @@ document.addEventListener('DOMContentLoaded', function() { } else { resultContainer.innerHTML = '

No suggestions available at the moment.

'; } + }) + .catch(error => { + console.error('Error loading category suggestions:', error); + resultContainer.innerHTML = '

Failed to load suggestions. Please try again.

'; }); this.style.display = 'none'; @@ -112,7 +250,10 @@ document.addEventListener('DOMContentLoaded', function() { const categoryLabel = categoryContainer.querySelector('.category-label').textContent.trim(); const resultContainer = this.closest('div').querySelector('.suggest-fields-container'); - fetch(`/api/v1/fields/suggest/${contentType}/${categoryLabel}`) + // Show loading animation in the suggestions container + showSuggestionsLoadingAnimation(resultContainer); + + fetch(`/plan/attribute_fields/suggest?content_type=${contentType}&category=${categoryLabel}`) .then(response => response.json()) .then(data => { const existingFields = Array.from(categoryContainer.querySelectorAll('.field-label')).map(el => el.textContent.trim()); @@ -132,6 +273,10 @@ document.addEventListener('DOMContentLoaded', function() { } else { resultContainer.innerHTML = '

No suggestions available at the moment.

'; } + }) + .catch(error => { + console.error('Error loading field suggestions:', error); + resultContainer.innerHTML = '

Failed to load suggestions. Please try again.

'; }); this.style.display = 'none'; @@ -158,18 +303,26 @@ function initSortables() { const categoryId = ui.item.attr('data-category-id'); const newPosition = ui.item.index(); - // AJAX request to update position + // AJAX request to update position using internal endpoint $.ajax({ - url: '/api/v1/content/sort', - type: 'PUT', + url: '/internal/sort/categories', + type: 'PATCH', contentType: 'application/json', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content') + }, data: JSON.stringify({ - sortable_class: 'AttributeCategory', content_id: categoryId, intended_position: newPosition }), success: function(data) { - console.log('Category position updated successfully'); + console.log('Category position updated successfully:', data); + if (data.message) { + showNotification(data.message, 'success'); + } + + // Update the position field in the category config form if it's open + updateCategoryConfigPosition(categoryId, data.category.position); }, error: function(xhr, status, error) { console.error('Error updating category position:', error); @@ -193,19 +346,27 @@ function initSortables() { const newPosition = ui.item.index(); const categoryId = ui.item.closest('.fields-container').attr('data-category-id'); - // AJAX request to update position + // AJAX request to update position using internal endpoint $.ajax({ - url: '/api/v1/content/sort', - type: 'PUT', + url: '/internal/sort/fields', + type: 'PATCH', contentType: 'application/json', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content') + }, data: JSON.stringify({ - sortable_class: 'AttributeField', content_id: fieldId, intended_position: newPosition, attribute_category_id: categoryId }), success: function(data) { - console.log('Field position updated successfully'); + console.log('Field position updated successfully:', data); + if (data.message) { + showNotification(data.message, 'success'); + } + + // Update the position field in the field config form if it's open + updateFieldConfigPosition(fieldId, data.field.position); }, error: function(xhr, status, error) { console.error('Error updating field position:', error); @@ -216,23 +377,790 @@ function initSortables() { }); } -// Function to show error messages to users -function showErrorMessage(message) { - // Use Materialize toast if available, otherwise create custom notification - if (typeof M !== 'undefined' && M.toast) { - M.toast({html: message, classes: 'red'}); - } else { - const notification = document.createElement('div'); - notification.className = 'fixed top-4 right-4 bg-red-500 text-white px-4 py-2 rounded-md shadow-lg z-50'; - notification.textContent = message; - document.body.appendChild(notification); - - setTimeout(() => { - if (notification.parentNode) { - notification.parentNode.removeChild(notification); +// Notification system +function showNotification(message, type = 'info') { + const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500'; + const icon = type === 'success' ? 'check_circle' : type === 'error' ? 'error' : 'info'; + + const notification = $(` +
+ ${icon} + ${message} + +
+ `); + + $('body').append(notification); + + // Auto-remove after 5 seconds + setTimeout(() => { + notification.fadeOut(300, function() { + $(this).remove(); + }); + }, 5000); +} + +// Make showNotification globally available +window.showNotification = showNotification; + +// Category visibility toggle function +window.toggleCategoryVisibility = function(categoryId, isHidden) { + const categoryCard = document.querySelector(`[data-category-id="${categoryId}"]`); + const button = categoryCard.querySelector('.category-visibility-toggle'); + + fetch(`/plan/attribute_categories/${categoryId}`, { + method: 'PUT', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + attribute_category: { + hidden: !isHidden } - }, 5000); + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success || data.message) { + // Update the UI manually instead of reloading + const newHiddenState = !isHidden; + + // Update button data attribute and title + button.setAttribute('data-hidden', newHiddenState); + button.setAttribute('title', newHiddenState ? 'Hidden category - Click to show' : 'Visible category - Click to hide'); + + // Update the eye icon + const eyeIcon = button.querySelector('svg'); + if (newHiddenState) { + // Show closed eye icon + eyeIcon.innerHTML = ''; + } else { + // Show open eye icon + eyeIcon.innerHTML = ''; + } + + // Update category card styling + if (newHiddenState) { + categoryCard.classList.add('border-gray-300'); + categoryCard.style.borderColor = ''; + categoryCard.querySelector('.category-header').style.backgroundColor = '#f9fafb'; + categoryCard.querySelector('.category-icon i').classList.add('text-gray-400'); + categoryCard.querySelector('.category-icon i').style.color = ''; + } else { + categoryCard.classList.remove('border-gray-300'); + const contentTypeColor = getComputedStyle(document.documentElement).getPropertyValue('--content-type-color') || '#6366f1'; + categoryCard.style.borderColor = contentTypeColor; + categoryCard.querySelector('.category-header').style.backgroundColor = contentTypeColor + '20'; + categoryCard.querySelector('.category-icon i').classList.remove('text-gray-400'); + categoryCard.querySelector('.category-icon i').style.color = contentTypeColor; + } + + // Update hidden status text + const statusText = categoryCard.querySelector('.category-label').parentElement.querySelector('p'); + if (newHiddenState) { + if (!statusText.textContent.includes('— Hidden')) { + statusText.innerHTML += '— Hidden'; + } + } else { + statusText.innerHTML = statusText.innerHTML.replace('— Hidden', ''); + } + + showNotification(data.message, 'success'); + } else { + showNotification('Failed to update category visibility', 'error'); + } + }) + .catch(error => { + console.error('Error toggling category visibility:', error); + showNotification('Failed to update category visibility', 'error'); + }); +}; + +// Field visibility toggle function +window.toggleFieldVisibility = function(fieldId, isHidden) { + const fieldItem = document.querySelector(`[data-field-id="${fieldId}"]`); + const button = fieldItem.querySelector('.field-visibility-toggle'); + + fetch(`/plan/attribute_fields/${fieldId}`, { + method: 'PUT', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + attribute_field: { + hidden: !isHidden + } + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success || data.message) { + // Update the UI manually instead of reloading + const newHiddenState = !isHidden; + + // Update button data attribute and title + button.setAttribute('data-hidden', newHiddenState); + button.setAttribute('title', newHiddenState ? 'Hidden field - Click to show' : 'Visible field - Click to hide'); + + // Update the eye icon + const eyeIcon = button.querySelector('svg'); + if (newHiddenState) { + // Show closed eye icon + eyeIcon.innerHTML = ''; + } else { + // Show open eye icon + eyeIcon.innerHTML = ''; + } + + // Update field item styling + if (newHiddenState) { + fieldItem.classList.add('bg-gray-50', 'border-gray-200'); + fieldItem.classList.remove('bg-white'); + fieldItem.querySelector('.field-label').classList.add('text-gray-500'); + fieldItem.querySelector('.field-label').classList.remove('text-gray-800'); + } else { + fieldItem.classList.remove('bg-gray-50', 'border-gray-200'); + fieldItem.classList.add('bg-white'); + fieldItem.querySelector('.field-label').classList.remove('text-gray-500'); + fieldItem.querySelector('.field-label').classList.add('text-gray-800'); + } + + // Update hidden status text in field info + const fieldInfo = fieldItem.querySelector('.text-xs.text-gray-500'); + if (newHiddenState) { + if (!fieldInfo.textContent.includes('— Hidden')) { + fieldInfo.innerHTML += '— Hidden'; + } + } else { + fieldInfo.innerHTML = fieldInfo.innerHTML.replace('— Hidden', ''); + } + + showNotification(data.message, 'success'); + } else { + showNotification('Failed to update field visibility', 'error'); + } + }) + .catch(error => { + console.error('Error toggling field visibility:', error); + showNotification('Failed to update field visibility', 'error'); + }); +}; + +// Category icon preview function +window.updateCategoryIconPreview = function(categoryId, iconName) { + // Update the icon preview in the category header + const categoryCard = document.querySelector(`[data-category-id="${categoryId}"]`); + if (categoryCard) { + const iconElement = categoryCard.querySelector('.category-icon i'); + if (iconElement) { + iconElement.textContent = iconName; + } } + + // Update the form to show the selected icon + const iconPreview = document.getElementById('selected-icon-preview'); + if (iconPreview) { + iconPreview.textContent = iconName; + } +}; + +// Function to show error messages to users (legacy compatibility) +function showErrorMessage(message) { + showNotification(message, 'error'); +} + +// Bind remote form handlers to dynamically loaded forms in a container +function bindRemoteFormsInContainer(containerId) { + const container = document.getElementById(containerId); + if (!container) return; + + // Find all forms with remote: true in the container + const remoteForms = container.querySelectorAll('form[data-remote="true"]'); + + remoteForms.forEach(form => { + // Remove any existing event listeners to prevent duplicates + form.removeEventListener('submit', handleRemoteFormSubmit); + + // Add our custom submit handler + form.addEventListener('submit', handleRemoteFormSubmit); + }); +} + +// Handle remote form submission manually +function handleRemoteFormSubmit(event) { + event.preventDefault(); // Prevent default form submission + + const form = event.target; + const formData = new FormData(form); + const submitButton = form.querySelector('input[type="submit"], button[type="submit"]'); + let originalText = ''; + + // Disable submit button to prevent double submission + if (submitButton) { + submitButton.disabled = true; + originalText = submitButton.value || submitButton.textContent; + if (submitButton.tagName === 'INPUT') { + submitButton.value = 'Saving...'; + } else { + submitButton.textContent = 'Saving...'; + } + + // Restore button after 3 seconds as fallback + setTimeout(() => { + submitButton.disabled = false; + if (submitButton.tagName === 'INPUT') { + submitButton.value = originalText; + } else { + submitButton.textContent = originalText; + } + }, 3000); + } + + // Convert FormData to JSON for Rails + const jsonData = {}; + for (let [key, value] of formData.entries()) { + // Skip Rails form helper fields + if (key === 'utf8' || key === '_method' || key === 'authenticity_token') { + continue; + } + + // Handle nested attributes properly - support multiple levels + if (key.includes('[') && key.includes(']')) { + // Parse nested field names like attribute_field[field_options][input_size] + const keyParts = key.split(/[\[\]]+/).filter(part => part !== ''); + + let current = jsonData; + for (let i = 0; i < keyParts.length - 1; i++) { + const part = keyParts[i]; + if (!current[part]) { + current[part] = {}; + } + current = current[part]; + } + + const finalKey = keyParts[keyParts.length - 1]; + current[finalKey] = value; + } else { + jsonData[key] = value; + } + } + + console.log('Converted form data:', jsonData); + + // Get the actual HTTP method from Rails form + let httpMethod = form.method.toUpperCase(); + + // Check for Rails method override (for PUT/PATCH/DELETE) + const methodInput = form.querySelector('input[name="_method"]'); + if (methodInput) { + httpMethod = methodInput.value.toUpperCase(); + } + + console.log('Submitting form with method:', httpMethod, 'to:', form.action); + + // Submit form via fetch + fetch(form.action, { + method: httpMethod, + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(jsonData) + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.json(); + }) + .then(data => { + console.log('Form submitted successfully:', data); + + // Re-enable submit button + if (submitButton) { + submitButton.disabled = false; + if (submitButton.tagName === 'INPUT') { + submitButton.value = originalText; + } else { + submitButton.textContent = originalText; + } + } + + // Show success notification + if (data.message) { + showNotification(data.message, 'success'); + } + + // Handle category form updates + if (form.action.includes('/attribute_categories/')) { + handleCategoryFormSuccess(form, data); + } + + // Handle field form updates + if (form.action.includes('/attribute_fields/')) { + handleFieldFormSuccess(form, data); + } + }) + .catch(error => { + console.error('Form submission failed:', error); + + // Re-enable submit button + if (submitButton) { + submitButton.disabled = false; + if (submitButton.tagName === 'INPUT') { + submitButton.value = originalText; + } else { + submitButton.textContent = originalText; + } + } + + showNotification('Failed to save changes', 'error'); + }); +} + +// Setup remote form handlers for dynamically loaded forms +function setupRemoteFormHandlers() { + // Handle successful form submissions (backup for Rails UJS if it works) + document.addEventListener('ajax:success', function(event) { + const form = event.target; + if (!form.matches('form[data-remote="true"]')) return; + + const response = event.detail[0]; + console.log('Form submitted successfully via Rails UJS:', response); + + // Show success notification + if (response.message) { + showNotification(response.message, 'success'); + } + + // Handle category form updates + if (form.action.includes('/attribute_categories/')) { + handleCategoryFormSuccess(form, response); + } + + // Handle field form updates + if (form.action.includes('/attribute_fields/')) { + handleFieldFormSuccess(form, response); + } + }); + + // Handle form submission errors (backup for Rails UJS if it works) + document.addEventListener('ajax:error', function(event) { + const form = event.target; + if (!form.matches('form[data-remote="true"]')) return; + + const response = event.detail[0]; + console.error('Form submission failed via Rails UJS:', response); + + let errorMessage = 'Failed to save changes'; + if (response && response.error) { + errorMessage = response.error; + } + + showNotification(errorMessage, 'error'); + }); +} + +// Handle successful category form submission +function handleCategoryFormSuccess(form, response) { + // Extract category ID from form action URL + const matches = form.action.match(/\/attribute_categories\/(\d+)/); + if (!matches) return; + + const categoryId = matches[1]; + const categoryCard = document.querySelector(`[data-category-id="${categoryId}"]`); + if (!categoryCard) return; + + // Update category display if label changed + if (response.category && response.category.label) { + const labelElement = categoryCard.querySelector('.category-label'); + if (labelElement) { + labelElement.textContent = response.category.label; + } + } + + // Update category icon if changed + if (response.category && response.category.icon) { + const iconElement = categoryCard.querySelector('.category-icon i'); + if (iconElement) { + iconElement.textContent = response.category.icon; + } + } + + // Handle visibility changes + if (response.category && typeof response.category.hidden !== 'undefined') { + const isHidden = response.category.hidden; + updateCategoryVisibilityUI(categoryId, isHidden); + } +} + +// Handle successful field form submission +function handleFieldFormSuccess(form, response) { + // Extract field ID from form action URL + const matches = form.action.match(/\/attribute_fields\/(\d+)/); + if (!matches) return; + + const fieldId = matches[1]; + const fieldItem = document.querySelector(`[data-field-id="${fieldId}"]`); + if (!fieldItem) return; + + // Update field display if label changed + if (response.field && response.field.label) { + const labelElement = fieldItem.querySelector('.field-label'); + if (labelElement) { + labelElement.textContent = response.field.label; + } + } + + // Handle visibility changes + if (response.field && typeof response.field.hidden !== 'undefined') { + const isHidden = response.field.hidden; + updateFieldVisibilityUI(fieldId, isHidden); + } +} + +// Update category visibility UI elements +function updateCategoryVisibilityUI(categoryId, isHidden) { + const categoryCard = document.querySelector(`[data-category-id="${categoryId}"]`); + if (!categoryCard) return; + + // Update visibility toggle button + const button = categoryCard.querySelector('.category-visibility-toggle'); + if (button) { + button.setAttribute('data-hidden', isHidden); + button.setAttribute('title', isHidden ? 'Hidden category - Click to show' : 'Visible category - Click to hide'); + + const eyeIcon = button.querySelector('svg'); + if (eyeIcon) { + if (isHidden) { + eyeIcon.innerHTML = ''; + } else { + eyeIcon.innerHTML = ''; + } + } + } + + // Update category card styling + if (isHidden) { + categoryCard.classList.add('border-gray-300'); + categoryCard.style.borderColor = ''; + categoryCard.querySelector('.category-header').style.backgroundColor = '#f9fafb'; + categoryCard.querySelector('.category-icon i').classList.add('text-gray-400'); + categoryCard.querySelector('.category-icon i').style.color = ''; + } else { + categoryCard.classList.remove('border-gray-300'); + const contentTypeColor = getComputedStyle(document.documentElement).getPropertyValue('--content-type-color') || '#6366f1'; + categoryCard.style.borderColor = contentTypeColor; + categoryCard.querySelector('.category-header').style.backgroundColor = contentTypeColor + '20'; + categoryCard.querySelector('.category-icon i').classList.remove('text-gray-400'); + categoryCard.querySelector('.category-icon i').style.color = contentTypeColor; + } + + // Update hidden status text + const statusText = categoryCard.querySelector('.category-label').parentElement.querySelector('p'); + if (statusText) { + if (isHidden) { + if (!statusText.textContent.includes('— Hidden')) { + statusText.innerHTML += '— Hidden'; + } + } else { + statusText.innerHTML = statusText.innerHTML.replace('— Hidden', ''); + } + } +} + +// Update field visibility UI elements +function updateFieldVisibilityUI(fieldId, isHidden) { + const fieldItem = document.querySelector(`[data-field-id="${fieldId}"]`); + if (!fieldItem) return; + + // Update visibility toggle button + const button = fieldItem.querySelector('.field-visibility-toggle'); + if (button) { + button.setAttribute('data-hidden', isHidden); + button.setAttribute('title', isHidden ? 'Hidden field - Click to show' : 'Visible field - Click to hide'); + + const eyeIcon = button.querySelector('svg'); + if (eyeIcon) { + if (isHidden) { + eyeIcon.innerHTML = ''; + } else { + eyeIcon.innerHTML = ''; + } + } + } + + // Update field item styling + if (isHidden) { + fieldItem.classList.add('bg-gray-50', 'border-gray-200'); + fieldItem.classList.remove('bg-white'); + fieldItem.querySelector('.field-label').classList.add('text-gray-500'); + fieldItem.querySelector('.field-label').classList.remove('text-gray-800'); + } else { + fieldItem.classList.remove('bg-gray-50', 'border-gray-200'); + fieldItem.classList.add('bg-white'); + fieldItem.querySelector('.field-label').classList.remove('text-gray-500'); + fieldItem.querySelector('.field-label').classList.add('text-gray-800'); + } + + // Update hidden status text + const fieldInfo = fieldItem.querySelector('.text-xs.text-gray-500'); + if (fieldInfo) { + if (isHidden) { + if (!fieldInfo.textContent.includes('— Hidden')) { + fieldInfo.innerHTML += '— Hidden'; + } + } else { + fieldInfo.innerHTML = fieldInfo.innerHTML.replace('— Hidden', ''); + } + } +} + +// Update category configuration form position field after drag and drop +function updateCategoryConfigPosition(categoryId, newPosition) { + // Check if the category config form is currently open + const alpineElement = document.querySelector('.attributes-editor'); + if (alpineElement && alpineElement._x_dataStack) { + const alpine = alpineElement._x_dataStack[0]; + + // Only update if this category's config form is currently open + if (alpine.selectedCategory == categoryId) { + const categoryConfigContainer = document.getElementById('category-config-container'); + if (categoryConfigContainer) { + const positionInput = categoryConfigContainer.querySelector('input[name="attribute_category[position]"]'); + if (positionInput) { + console.log(`Updating category ${categoryId} position input from ${positionInput.value} to ${newPosition}`); + positionInput.value = newPosition; + } + } + } + } +} + +// Update field configuration form position field after drag and drop +function updateFieldConfigPosition(fieldId, newPosition) { + // Check if the field config form is currently open + const alpineElement = document.querySelector('.attributes-editor'); + if (alpineElement && alpineElement._x_dataStack) { + const alpine = alpineElement._x_dataStack[0]; + + // Only update if this field's config form is currently open + if (alpine.selectedField == fieldId) { + const fieldConfigContainer = document.getElementById('field-config-container'); + if (fieldConfigContainer) { + const positionInput = fieldConfigContainer.querySelector('input[name="attribute_field[position]"]'); + if (positionInput) { + console.log(`Updating field ${fieldId} position input from ${positionInput.value} to ${newPosition}`); + positionInput.value = newPosition; + } + } + } + } +} + +// Show loading animation for configuration panels +function showConfigLoadingAnimation(containerId) { + const container = document.getElementById(containerId); + if (!container) return; + + // Create loading bar element + const loadingBar = document.createElement('div'); + loadingBar.className = 'config-loading-bar'; + loadingBar.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+
+
+
+ `; + + // Add loading styles + const style = document.createElement('style'); + style.textContent = ` + .config-loading-bar { + position: relative; + padding: 1rem; + } + + .loading-bar-container { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background-color: #f3f4f6; + overflow: hidden; + } + + .loading-bar-progress { + height: 100%; + background: linear-gradient(90deg, var(--content-type-color, #6366f1) 0%, rgba(99, 102, 241, 0.6) 50%, var(--content-type-color, #6366f1) 100%); + background-size: 200% 100%; + animation: loading-slide 1.5s ease-in-out infinite; + } + + @keyframes loading-slide { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } + } + + .loading-content { + margin-top: 1rem; + } + `; + + // Add styles to head if not already present + if (!document.querySelector('.config-loading-styles')) { + style.classList.add('config-loading-styles'); + document.head.appendChild(style); + } + + // Replace container content with loading animation + container.innerHTML = ''; + container.appendChild(loadingBar); +} + +// Hide loading animation for configuration panels +function hideConfigLoadingAnimation(containerId) { + // The loading animation will be replaced when the actual content loads + // This function is called after the content is set, so it's mainly for cleanup + // and error handling scenarios + + // Remove any loading-specific styles or elements if needed + const container = document.getElementById(containerId); + if (container) { + const loadingBar = container.querySelector('.config-loading-bar'); + if (loadingBar) { + loadingBar.remove(); + } + } +} + +// Show loading animation for suggestions containers +function showSuggestionsLoadingAnimation(container) { + if (!container) return; + + // Create compact loading indicator for suggestions + const loadingIndicator = document.createElement('div'); + loadingIndicator.className = 'suggestions-loading-indicator'; + loadingIndicator.innerHTML = ` +
+
+
+
+
+
+
+
+
+ Loading suggestions... +
+ `; + + // Add suggestion loading styles if not already present + if (!document.querySelector('.suggestions-loading-styles')) { + const style = document.createElement('style'); + style.classList.add('suggestions-loading-styles'); + style.textContent = ` + .suggestions-loading-indicator { + position: relative; + border: 1px solid #e5e7eb; + border-radius: 0.375rem; + background-color: #f9fafb; + margin: 0.5rem 0; + padding: 0.75rem; + } + + .suggestions-loading-indicator .loading-bar-container { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background-color: #f3f4f6; + overflow: hidden; + border-radius: 0.375rem 0.375rem 0 0; + } + + .suggestions-loading-indicator .loading-bar-progress { + height: 100%; + background: linear-gradient(90deg, var(--content-type-color, #6366f1) 0%, rgba(99, 102, 241, 0.6) 50%, var(--content-type-color, #6366f1) 100%); + background-size: 200% 100%; + animation: loading-slide 1.2s ease-in-out infinite; + } + + .suggestions-loading-indicator .loading-content { + display: flex; + align-items: center; + justify-content: center; + padding-top: 0.25rem; + } + + .suggestions-loading-indicator .loading-dots { + display: flex; + align-items: center; + margin-right: 0.5rem; + } + + .suggestions-loading-indicator .loading-dots .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: #9ca3af; + margin: 0 2px; + animation: loading-dots 1.4s ease-in-out infinite both; + } + + .suggestions-loading-indicator .loading-dots .dot:nth-child(1) { + animation-delay: -0.32s; + } + + .suggestions-loading-indicator .loading-dots .dot:nth-child(2) { + animation-delay: -0.16s; + } + + .suggestions-loading-indicator .loading-text { + font-size: 0.875rem; + color: #6b7280; + font-weight: 500; + } + + @keyframes loading-dots { + 0%, 80%, 100% { + transform: scale(0.8); + opacity: 0.5; + } + 40% { + transform: scale(1); + opacity: 1; + } + } + `; + document.head.appendChild(style); + } + + // Replace container content with loading animation + container.innerHTML = ''; + container.appendChild(loadingIndicator); } // Detect screen size changes to adjust UI diff --git a/app/models/concerns/has_attributes.rb b/app/models/concerns/has_attributes.rb index c69dd5f7..bae7c9de 100644 --- a/app/models/concerns/has_attributes.rb +++ b/app/models/concerns/has_attributes.rb @@ -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) diff --git a/app/services/template_initialization_service.rb b/app/services/template_initialization_service.rb new file mode 100644 index 00000000..bcc8cd2e --- /dev/null +++ b/app/services/template_initialization_service.rb @@ -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 \ No newline at end of file diff --git a/app/services/template_reset_service.rb b/app/services/template_reset_service.rb new file mode 100644 index 00000000..c23a7302 --- /dev/null +++ b/app/services/template_reset_service.rb @@ -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 \ No newline at end of file diff --git a/app/views/content/attributes/tailwind/_category_card.html.erb b/app/views/content/attributes/tailwind/_category_card.html.erb index 2684b1f5..cb63f2ae 100644 --- a/app/views/content/attributes/tailwind/_category_card.html.erb +++ b/app/views/content/attributes/tailwind/_category_card.html.erb @@ -76,8 +76,7 @@
+ class="p-4 category-body">
diff --git a/app/views/content/attributes/tailwind/_category_config.html.erb b/app/views/content/attributes/tailwind/_category_config.html.erb index 9d253b16..3af1482f 100644 --- a/app/views/content/attributes/tailwind/_category_config.html.erb +++ b/app/views/content/attributes/tailwind/_category_config.html.erb @@ -58,11 +58,6 @@ <%= f.hidden_field :icon, id: 'attribute_category_icon' %>
-
- - <%= 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};" %> -

Lower numbers appear first. You can also drag and drop to reorder.

-
<%= 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 @@
+
+
Display Order
+
+

+ Categories are displayed in order on content pages. Lower numbers appear first. +

+

+ Tip: Use drag & drop in the template editor for easier reordering. This manual control is for fine-tuning. +

+ <%= form_for(category, url: "/plan/attribute_categories/#{category.id}", method: :put, html: {class: 'space-y-3', 'data-type': 'json'}, remote: true) do |f| %> +
+ + <%= 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};" %> +
+ <% end %> +
+
+
Category Visibility
diff --git a/app/views/content/attributes/tailwind/_field_config.html.erb b/app/views/content/attributes/tailwind/_field_config.html.erb index 9ad16bf9..21dc860c 100644 --- a/app/views/content/attributes/tailwind/_field_config.html.erb +++ b/app/views/content/attributes/tailwind/_field_config.html.erb @@ -68,11 +68,53 @@
-
- - <%= 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};" %> -

Lower numbers appear first. You can also drag and drop to reorder.

-
+ <% if field.field_type == 'link' %> +
+
+ +
+ + +
+
+

Select which content types users can link to from this field.

+ + + + +
+ <% Rails.application.config.content_types[:all].each do |content_type| %> + + <% end %> +
+
+ <% end %>
<%= 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 @@
<% if field.field_type == 'text_area' %> -
- - - - -
- -
- - - - - - - - -
-
-
- <% 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| %> -
-
- -
- - + <%= form_for(field, method: :put, html: {class: 'space-y-4', 'data-type': 'json'}, remote: true) do |f| %> +
+ + + + +
+ +
+ + + + + + + +
-

Select which content types users can link to from this field.

- - - - -
- <% Rails.application.config.content_types[:all].each do |content_type| %> - - <% end %> -
- <%= 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};" %>
<% end %> + <% elsif field.field_type == 'link' %> +
+

+ Choose how linked content appears on your pages. +

+ + <%= form_for(field, method: :put, html: {class: 'space-y-4', 'data-type': 'json'}, remote: true) do |f| %> +
+ + + +
+ + + + + +
+ + +
+ +
+
+
• Alice Johnson
+
• The Rusty Tavern
+
• Magic Sword
+
+
+
+ 📝 Alice Johnson + 🏠 The Rusty Tavern + ⚔️ Magic Sword +
+
+
+
+ Alice Johnson + The Rusty Tavern + Magic Sword +
+
+
+
+ +
+ <%= 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};" %> +
+
+ <% end %> +
+ <% else %> +
+
+ palette +
+

+ No appearance options available for this field type. +

+
<% end %>
@@ -180,9 +273,34 @@
+
+
Display Order
+
+

+ Fields are displayed in order within their category. Lower numbers appear first. +

+

+ Tip: Use drag & drop in the template editor for easier reordering. This manual control is for fine-tuning. +

+ <%= form_for(field, method: :put, html: {class: 'space-y-3', 'data-type': 'json'}, remote: true) do |f| %> +
+ + <%= 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};" %> +
+ <% end %> +
+
+
Field Visibility
+
+
+
Hidden fields: Completely removed from content pages - not shown or used at all
+
Private fields: Only visible to you, even when your content page is public
+
+
<%= 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? %> diff --git a/app/views/content/attributes/tailwind/_field_item.html.erb b/app/views/content/attributes/tailwind/_field_item.html.erb index cec99a36..53ae3d5f 100644 --- a/app/views/content/attributes/tailwind/_field_item.html.erb +++ b/app/views/content/attributes/tailwind/_field_item.html.erb @@ -58,18 +58,8 @@
- - - - <% unless field.name_field? || field.universe_field? || field.tags_field? %> + <% unless field.name_field? %> - - - <% end %>
\ No newline at end of file diff --git a/app/views/content/attributes/tailwind/_general_settings.html.erb b/app/views/content/attributes/tailwind/_general_settings.html.erb index f057a46c..b32d690a 100644 --- a/app/views/content/attributes/tailwind/_general_settings.html.erb +++ b/app/views/content/attributes/tailwind/_general_settings.html.erb @@ -89,16 +89,107 @@ chevron_right - + +
+ + +
+
+

Analyzing template...

+
+ + +
+
+

+ warning + Reset Impact Analysis +

+
+

categories and fields will be deleted

+

custom categories you created

+

+ fields have data that will be permanently lost! +

+

+ filled answers will be deleted across different pages +

+
+
+ + +
+
⚠️ Data Loss Warning
+
+ +
+
+ +
+ + +
+
+ + +
+
+

⚠️ Final Confirmation Required

+

+ Type "<%= content_type.titleize %>" below to confirm you want to permanently reset this template: +

+ +
+ +
+ + +
- chevron_right - +
diff --git a/app/views/content/attributes_tailwind.html.erb b/app/views/content/attributes_tailwind.html.erb index 6373b9f0..0e97e66d 100644 --- a/app/views/content/attributes_tailwind.html.erb +++ b/app/views/content/attributes_tailwind.html.erb @@ -2,15 +2,13 @@ <%= render partial: 'content/components/parallax_header', locals: { content_type: @content_type, content_class: @content_type_class } %> <% end %> +<%= javascript_pack_tag 'template_editor' %> + +
+ x-data="initTemplateEditor()">
@@ -42,13 +40,6 @@ <%= @content_type_class.icon %> <%= @content_type.titleize %> Template -
- -

@@ -170,1275 +161,7 @@

- \ No newline at end of file + + + \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 38476b7a..90f905fb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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'