new template editor

This commit is contained in:
Andrew Brown 2025-07-02 22:35:56 -07:00
parent 7af35d1946
commit df56671ed3
21 changed files with 3506 additions and 972 deletions

View File

@ -5,8 +5,23 @@ $(document).ready(function () {
$.ajax({
dataType: "json",
url: "/api/v1/categories/suggest/" + content_type,
url: "/plan/attribute_categories/suggest?content_type=" + content_type,
success: function (data) {
console.log('Categories suggestion data received:', data);
console.log('Data type:', typeof data);
console.log('Is array:', Array.isArray(data));
// If data is a string, try to parse it as JSON
if (typeof data === 'string') {
try {
data = JSON.parse(data);
console.log('Parsed data:', data);
} catch (e) {
console.error('Failed to parse JSON:', e);
return;
}
}
var existing_categories = $('.js-category-label').map(function(){
return $.trim($(this).text());
}).get();
@ -45,7 +60,7 @@ $(document).ready(function () {
$.ajax({
dataType: "json",
url: "/api/v1/fields/suggest/" + content_type + "/" + category_label,
url: "/plan/attribute_fields/suggest?content_type=" + content_type + "&category=" + category_label,
success: function (data) {
// console.log("new fields");
// console.log(data);

View File

@ -1,20 +1,41 @@
module Api
module V1
class AttributeCategoriesController < ApiController
before_action :authenticate_user!, only: [:edit]
before_action :set_category, only: [:edit]
def suggest
suggestions = AttributeCategorySuggestion.where(entity_type: params.fetch(:entity_type, '').downcase)
# Handle both :content_type (from /api/v1/categories/suggest/:content_type)
# and :entity_type (from other routes) for backward compatibility
entity_type = params.fetch(:content_type, params.fetch(:entity_type, '')).downcase
suggestions = AttributeCategorySuggestion.where(entity_type: entity_type)
.where.not(suggestion: AttributeCategorySuggestion::BLACKLISTED_LABELS)
.order('weight desc')
.limit(AttributeCategorySuggestion::SUGGESTIONS_RESULT_COUNT)
.pluck(:suggestion)
if suggestions.empty?
CacheMostUsedAttributeCategoriesJob.perform_later(
params.fetch(:entity_type, nil)
)
CacheMostUsedAttributeCategoriesJob.perform_later(entity_type)
end
render json: suggestions.to_json
render json: suggestions
end
def edit
authorize_action_for @category
content_type_class = @category.entity_type.constantize
render partial: 'content/attributes/tailwind/category_config', locals: {
category: @category,
content_type_class: content_type_class,
content_type: @category.entity_type.downcase
}
end
private
def set_category
@category = AttributeCategory.find(params[:id])
end
end
end

View File

@ -1,22 +1,43 @@
module Api
module V1
class AttributeFieldsController < ApiController
before_action :authenticate_user!, only: [:edit]
before_action :set_field, only: [:edit]
def suggest
# Handle both :content_type (from /api/v1/fields/suggest/:content_type/:category)
# and :entity_type (from other routes) for backward compatibility
entity_type = params.fetch(:content_type, params.fetch(:entity_type, '')).downcase
category = params.fetch(:category, '')
suggestions = AttributeFieldSuggestion.where(
entity_type: params.fetch(:entity_type, '').downcase,
category_label: params.fetch(:category, '')
entity_type: entity_type,
category_label: category
).where.not(suggestion: [nil, ""]).order('weight desc').limit(
AttributeFieldSuggestion::SUGGESTIONS_RESULT_COUNT
).pluck(:suggestion).uniq
if suggestions.empty?
CacheMostUsedAttributeFieldsJob.perform_later(
params.fetch(:entity_type, nil),
params.fetch(:category, nil)
)
CacheMostUsedAttributeFieldsJob.perform_later(entity_type, category)
end
render json: suggestions.to_json
render json: suggestions
end
def edit
authorize_action_for @field
content_type_class = @field.attribute_category.entity_type.constantize
render partial: 'content/attributes/tailwind/field_config', locals: {
field: @field,
content_type_class: content_type_class,
content_type: @field.attribute_category.entity_type.downcase
}
end
private
def set_field
@field = AttributeField.find(params[:id])
end
end
end

View File

@ -1,9 +1,116 @@
module Api
module V1
class AttributesController < ApiController
def suggest
raise "NotImplementedYet".inspect
before_action :authenticate_user!
# Category endpoints
def category_edit
@category = AttributeCategory.find(params[:id])
authorize_action_for @category
@content_type_class = @category.entity_type.constantize
render partial: 'content/attributes/tailwind/category_config', locals: {
category: @category,
content_type_class: @content_type_class,
content_type: @category.entity_type.downcase
}
end
# Field endpoints
def field_edit
@field = AttributeField.find(params[:id])
authorize_action_for @field
@category = @field.attribute_category
@content_type_class = @category.entity_type.constantize
render partial: 'content/attributes/tailwind/field_config', locals: {
field: @field,
content_type_class: @content_type_class,
content_type: @category.entity_type.downcase
}
end
# Sort endpoint (handles both categories and fields)
def sort
sortable_class = params[:sortable_class]
content_id = params[:content_id]
intended_position = params[:intended_position].to_i
if sortable_class == 'AttributeCategory'
category = AttributeCategory.find(content_id)
authorize_action_for category
# Update category position
category.update(position: intended_position)
# Update other categories' positions
categories = category.user.attribute_categories
.where(entity_type: category.entity_type)
.where.not(id: category.id)
.order(:position)
# Reposition categories
position = 0
categories.each do |c|
position += 1
position += 1 if position == intended_position
c.update_columns(position: position)
end
render json: { success: true }
elsif sortable_class == 'AttributeField'
field = AttributeField.find(content_id)
authorize_action_for field
# Check if field is moving to a different category
if params[:attribute_category_id].present? &&
field.attribute_category_id.to_s != params[:attribute_category_id].to_s
# Update field's category
new_category = AttributeCategory.find(params[:attribute_category_id])
authorize_action_for new_category
field.update(attribute_category_id: new_category.id, position: intended_position)
# Update positions in the new category
fields = new_category.attribute_fields
.where.not(id: field.id)
.order(:position)
position = 0
fields.each do |f|
position += 1
position += 1 if position == intended_position
f.update_columns(position: position)
end
else
# Just update position within current category
field.update(position: intended_position)
# Update other fields' positions
fields = field.attribute_category.attribute_fields
.where.not(id: field.id)
.order(:position)
position = 0
fields.each do |f|
position += 1
position += 1 if position == intended_position
f.update_columns(position: position)
end
end
render json: { success: true }
else
render json: { error: "Invalid sortable class" }, status: :unprocessable_entity
end
end
# API suggestion endpoint
def suggest
# For compatibility with existing code
entity_type = params[:entity_type]
field_label = params[:field_label]
return unless Rails.application.config.content_types[:all].map(&:name).include?(entity_type)
@ -12,17 +119,11 @@ module Api
label: field_label,
field_type: 'text_area'
).pluck(:id)
# Return sample suggestions for now
suggestions = ["Example 1", "Example 2", "Example 3"]
# This is too slow. Need DB indexes?
# suggestions = Attribute.where(attribute_field_id: field_ids, entity_type: entity_type)
# .where.not(value: [nil, ""])
# .group(:value)
# .order('count_id DESC')
# .limit(50)
# .count(:id)
# .reject { |_, count| count < 1 }
render json: suggestions.to_json
render json: suggestions
end
end
end

View File

@ -0,0 +1,37 @@
module Api
module V1
class ContentController < ApiController
before_action :authenticate_user!
def sort
sortable_class = params[:sortable_class]
content_id = params[:content_id]
intended_position = params[:intended_position].to_i
if sortable_class == 'AttributeCategory'
category = AttributeCategory.find(content_id)
authorize_action_for category
# Update category position
category.update(position: intended_position)
render json: { status: :ok }
elsif sortable_class == 'AttributeField'
field = AttributeField.find(content_id)
authorize_action_for field
# Update field position
field.update(position: intended_position)
render json: { status: :ok }
else
render json: { status: :error, message: 'Invalid sortable class' }, status: :unprocessable_entity
end
rescue ActiveRecord::RecordNotFound => e
render json: { status: :error, message: 'Record not found' }, status: :not_found
rescue StandardError => e
render json: { status: :error, message: e.message }, status: :internal_server_error
end
end
end
end

View File

@ -1,19 +1,156 @@
# Controller for the Attribute model
class AttributeCategoriesController < ContentController
before_action :authenticate_user!
before_action :set_attribute_category, only: [:edit, :update, :destroy]
def edit
unless @attribute_category.readable_by?(current_user)
render json: { error: "You don't have permission to edit that!" }, status: :forbidden
return
end
content_type_class = @attribute_category.entity_type.classify.constantize
render partial: 'content/attributes/tailwind/category_config', locals: {
category: @attribute_category,
content_type_class: content_type_class,
content_type: @attribute_category.entity_type.downcase
}
end
def create
initialize_object.save!
redirect_to(
attribute_customization_path(content_type: @content.entity_type),
notice: "Shiny new #{@content.label} category created!"
)
if initialize_object.save!
@content = @attribute_category if @attribute_category
@content ||= initialize_object
message = "Shiny new #{@content.label} category created!"
successful_response(@content, message)
else
failed_response(
'create',
:unprocessable_entity,
"Unable to create category. Error code: " + (@content&.errors&.to_json || 'Unknown error')
)
end
end
def update
unless @attribute_category.updatable_by?(current_user)
flash[:notice] = "You don't have permission to edit that!"
return redirect_back fallback_location: root_path
end
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]}"
else
"#{@attribute_category.label} category updated successfully!"
end
successful_response(@attribute_category, message)
else
failed_response(
'edit',
:unprocessable_entity,
"Unable to save category. Error code: " + @attribute_category.errors.to_json
)
end
end
def destroy
unless @attribute_category.deletable_by?(current_user)
respond_to do |format|
format.html {
flash[:notice] = "You don't have permission to delete that!"
redirect_back fallback_location: root_path
}
format.json { render json: { error: "You don't have permission to delete that!" }, status: :forbidden }
end
return
end
category_id = @attribute_category.id
category_label = @attribute_category.label
category_entity_type = @attribute_category.entity_type
field_count = @attribute_category.attribute_fields.count
# Delete the category (this will cascade delete all fields and their answers)
@attribute_category.destroy
respond_to do |format|
format.html {
redirect_to(
attribute_customization_tailwind_path(content_type: category_entity_type),
notice: "#{category_label} category deleted!"
)
}
format.json {
render json: {
success: true,
message: "#{category_label} category and its #{field_count} #{'field'.pluralize(field_count)} have been permanently deleted.",
category_id: category_id
}, status: :ok
}
end
end
def suggest
entity_type = params.fetch(:content_type, '').downcase
suggestions = AttributeCategorySuggestion.where(entity_type: entity_type)
.where.not(suggestion: AttributeCategorySuggestion::BLACKLISTED_LABELS)
.order('weight desc')
.limit(AttributeCategorySuggestion::SUGGESTIONS_RESULT_COUNT)
.pluck(:suggestion)
if suggestions.empty?
CacheMostUsedAttributeCategoriesJob.perform_later(entity_type)
end
render json: suggestions
end
private
def successful_response(url, notice)
def failed_response(action, status, message)
respond_to do |format|
format.html { redirect_to attribute_customization_path(content_type: @content.entity_type), notice: notice }
format.json { render json: @content || {}, status: :success, notice: notice }
format.html { redirect_back fallback_location: root_path, alert: message }
format.json { render json: { error: message }, status: status }
end
end
def successful_response(record, notice)
respond_to do |format|
format.html {
redirect_to attribute_customization_tailwind_path(content_type: record.entity_type), notice: notice
}
format.json {
response_data = {
success: true,
message: notice,
category: {
id: record.id,
hidden: record.hidden,
label: record.label,
icon: record.icon,
entity_type: record.entity_type,
position: record.position
}
}
render json: response_data, status: :ok
}
end
end
def initialize_object
@content = current_user.attribute_categories.find_or_initialize_by(content_params.except(:field_options)).tap do |category|
category.user_id = current_user.id
end
end
@ -25,7 +162,11 @@ class AttributeCategoriesController < ContentController
[
:user_id, :entity_type,
:name, :label, :icon, :description,
:hidden
:hidden, :position
]
end
def set_attribute_category
@attribute_category = current_user.attribute_categories.find(params[:id])
end
end

View File

@ -1,14 +1,21 @@
class AttributeFieldsController < ContentController
before_action :authenticate_user!
before_action :set_attribute_field, only: [:update]
before_action :set_attribute_field, only: [:update, :edit]
def create
initialize_object.save!
redirect_back(
fallback_location: attribute_customization_path(content_type: @content.attribute_category.entity_type),
notice: "Nifty new #{@content.label} field created!"
)
if initialize_object.save!
@content = @attribute_field if @attribute_field
@content ||= initialize_object
message = "Nifty new #{@content.label} field created!"
successful_response(@content, message)
else
failed_response(
'create',
:unprocessable_entity,
"Unable to create field. Error code: " + (@content&.errors&.to_json || 'Unknown error')
)
end
end
def destroy
@ -20,18 +27,48 @@ class AttributeFieldsController < ContentController
related_category.destroy if related_category.attribute_fields.empty?
end
def edit
unless @attribute_field.readable_by?(current_user)
render json: { error: "You don't have permission to edit that!" }, status: :forbidden
return
end
content_type_class = @attribute_field.attribute_category.entity_type.classify.constantize
render partial: 'content/attributes/tailwind/field_config', locals: {
field: @attribute_field,
content_type_class: content_type_class,
content_type: @attribute_field.attribute_category.entity_type.downcase
}
end
def update
unless @attribute_field.updatable_by?(current_user)
flash[:notice] = "You don't have permission to edit that!"
return redirect_back fallback_location: root_path
end
if @attribute_field.update(content_params.merge({ migrated_from_legacy: true }))
# Clean up field_options to handle empty linkable_types arrays properly
cleaned_params = content_params.dup
if cleaned_params[:field_options] && cleaned_params[:field_options][:linkable_types]
# Remove empty strings from linkable_types array
cleaned_params[:field_options][:linkable_types] = cleaned_params[:field_options][:linkable_types].reject(&:blank?)
end
if @attribute_field.update(cleaned_params.merge({ migrated_from_legacy: true }))
@content = @attribute_field
successful_response(
@attribute_field,
t(:update_success, model_name: @attribute_field.label)
)
# 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]}"
else
"#{@attribute_field.label} field updated successfully!"
end
successful_response(@attribute_field, message)
else
failed_response(
'edit',
@ -41,16 +78,41 @@ class AttributeFieldsController < ContentController
end
end
def suggest
entity_type = params.fetch(:content_type, '').downcase
category = params.fetch(:category, '')
suggestions = AttributeFieldSuggestion.where(
entity_type: entity_type,
category_label: category
).where.not(suggestion: [nil, ""]).order('weight desc').limit(
AttributeFieldSuggestion::SUGGESTIONS_RESULT_COUNT
).pluck(:suggestion).uniq
if suggestions.empty?
CacheMostUsedAttributeFieldsJob.perform_later(entity_type, category)
end
render json: suggestions
end
private
def initialize_object
@content = AttributeField.find_or_initialize_by(content_params.except(:field_options)).tap do |field|
@attribute_field = AttributeField.find_or_initialize_by(content_params.except(:field_options)).tap do |field|
field.user_id = current_user.id
field.field_options = content_params.fetch(:field_options, {})
# Clean up field_options to handle empty linkable_types arrays properly
field_options = content_params.fetch(:field_options, {})
if field_options[:linkable_types]
field_options[:linkable_types] = field_options[:linkable_types].reject(&:blank?)
end
field.field_options = field_options
field.migrated_from_legacy = true
end
if @content.attribute_category_id.nil?
if @attribute_field.attribute_category_id.nil?
category = current_user.attribute_categories.where(id: content_params[:attribute_category_id]).first
if category.nil?
@ -60,10 +122,10 @@ class AttributeFieldsController < ContentController
end
end
@content.attribute_category_id = category.id
@attribute_field.attribute_category_id = category.id
end
@content
@attribute_field
end
def content_deletion_redirect_url
@ -73,16 +135,63 @@ class AttributeFieldsController < ContentController
def content_creation_redirect_url
if @content.present?
category = @content.attribute_category
attribute_customization_path(content_type: category.entity_type)
attribute_customization_tailwind_path(content_type: category.entity_type)
else
:back
end
end
def successful_response(url, notice)
def successful_response(record, notice)
respond_to do |format|
format.html { redirect_to attribute_customization_path(content_type: @content.attribute_category.entity_type), notice: notice }
format.json { render json: @content || {}, status: :success, notice: notice }
format.html {
redirect_to attribute_customization_tailwind_path(content_type: record.attribute_category.entity_type), notice: notice
}
format.json {
# Get the content type class for the partial
content_type_class = record.attribute_category.entity_type.classify.constantize
# Create a temporary HTML response context to render the partial
field_html = nil
begin
# Force the lookup context to use HTML templates
old_formats = lookup_context.formats
lookup_context.formats = [:html]
field_html = render_to_string(
partial: 'content/attributes/tailwind/field_item',
locals: {
field: record,
content_type_class: content_type_class,
content_type: record.attribute_category.entity_type.downcase
}
)
ensure
# Restore original formats
lookup_context.formats = old_formats
end
response_data = {
success: true,
message: notice,
field: {
id: record.id,
hidden: record.hidden,
label: record.label,
field_type: record.field_type,
attribute_category_id: record.attribute_category_id,
position: record.position
},
html: field_html
}
render json: response_data, status: :ok
}
end
end
def failed_response(action, status, message)
respond_to do |format|
format.html { redirect_back fallback_location: root_path, alert: message }
format.json { render json: { success: false, error: message }, status: status }
end
end
@ -102,8 +211,10 @@ class AttributeFieldsController < ContentController
:label, :description,
:entity_type,
:attribute_category_id,
:hidden,
field_options: {}
:hidden, :position,
field_options: {
linkable_types: []
}
]
end

View File

@ -1,465 +1,11 @@
# frozen_string_literal: true
# TODO: we should probably spin off an Api::ContentController for #api_sort and anything else
# api-wise we need
# Controller for CRUD actions related to any content
class ContentController < ApplicationController
layout 'tailwind', only: [:index, :show, :gallery, :references, :deleted]
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]
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))
@serialized_content = ContentSerializer.new(@content)
@primary_image = @content.primary_image
@other_images = @content.image_uploads.where.not(id: @primary_image.try(:id))
@basil_images = @content.basil_commissions.where.not(saved_at: nil)
end
def references
content_type = content_type_from_controller(self.class)
return redirect_to(root_path, notice: "That page doesn't exist!") unless valid_content_types.include?(content_type.name)
@content = content_type.find_by(id: params[:id])
return redirect_to(root_path, notice: "You don't have permission to view that content.") if @content.nil?
return redirect_to(root_path) if @content.user.nil? # deleted user's content
return if ENV.key?('CONTENT_BLACKLIST') && ENV['CONTENT_BLACKLIST'].split(',').include?(@content.user.try(:email))
@serialized_content = ContentSerializer.new(@content)
analysis_ids = DocumentEntity.where(entity: @content).pluck(:document_analysis_id)
document_ids = DocumentAnalysis.where(id: analysis_ids).pluck(:document_id)
@documents = Document.where(id: document_ids)
@references = @content.incoming_page_references.preload(:referencing_page)
@mentioning_attributes = Attribute.where(
attribute_field_id: @references.pluck(:attribute_field_id),
entity_id: @references.pluck(:referencing_page_id)
)
end
def new
@content = content_type_from_controller(self.class)
.new(user: current_user)
.tap { |content|
content.name = "New #{content.class.name}"
content.universe_id = @universe_scope.try(:id) if content.respond_to?(:universe_id)
}
current_users_categories_and_fields = @content.class.attribute_categories(current_user)
if current_users_categories_and_fields.empty?
content_type_from_controller(self.class).create_default_attribute_categories(current_user)
current_users_categories_and_fields = @content.class.attribute_categories(current_user)
end
if user_signed_in? && current_user.can_create?(@content.class) \
|| PermissionService.user_has_active_promotion_for_this_content_type(user: current_user, content_type: @content.class.name)
# For users who are creating premium content in a collaborated universe without premium of their own
# we want to default that content into one of their collaborated unvierses.
if !current_user.on_premium_plan? && Rails.application.config.content_types[:premium].map(&:name).include?(@content.class.name)
@content.universe_id = current_user.contributable_universes.first.try(:id)
end
if params.key?(:document_entity)
entity = DocumentEntity.find_by(id: params.fetch(:document_entity).to_i)
if entity.document_owner == current_user
# Link the new page to the document entity
@content.name = entity.text # cached name value
@content.document_entity_id = entity.id
# Since we're creating a new page here, we need to make sure we save it before requesting
# a name field, since they're keyed off content IDs (and we don't have an ID before saving).
@content.save!
# Update the actual AttributeField's value for this page's name also
@content.set_name_field_value(entity.text)
end
end
@content.save!
# If the user doesn't have this content type enabled, go ahead and automatically enable it for them
current_user.user_content_type_activators.find_or_create_by(content_type: @content.class.name)
return redirect_to polymorphic_path(@content, editing: true)
else
return redirect_to(subscription_path, notice: "#{@content.class.name.pluralize} require a Premium subscription to create.")
end
end
def edit
content_type_class = content_type_from_controller(self.class)
@content = content_type_class.find_by(id: params[:id])
if @content.nil?
return redirect_to(root_path,
notice: "Either this #{content_type_class.name.downcase} doesn't exist, or you don't have access to view it."
)
end
@serialized_content = ContentSerializer.new(@content)
@suggested_page_tags = (
current_user.page_tags.where(page_type: content_type_class.name).pluck(:tag) +
PageTagService.suggested_tags_for(content_type_class.name)
).uniq - @serialized_content.page_tags
unless @content.updatable_by? current_user
return redirect_to @content, notice: t(:no_do_permission)
end
@random_image_including_private_pool_cache = ImageUpload.where(
user_id: current_user.id,
).group_by { |image| [image.content_type, image.content_id] }
@basil_images = BasilCommission.where(entity: @content)
.where.not(saved_at: nil)
respond_to do |format|
format.html { render 'content/edit', locals: { content: @content } }
format.json { render json: @content }
end
end
def create
content_type = content_type_from_controller(self.class)
initialize_object
unless current_user.can_create?(content_type) \
|| PermissionService.user_has_active_promotion_for_this_content_type(user: current_user, content_type: content_type.name)
return redirect_back(fallback_location: root_path, notice: "Creating this type of page requires an active Premium subscription.")
end
# Default names to untitled until one has been set
unless [AttributeCategory, AttributeField, Attribute].map(&:name).include?(@content.class.name)
@content.name ||= "Untitled #{content_type.name.downcase}"
end
# Default owner to the current user
@content.user_id ||= current_user.id
if @content.save
cache_params = {}
cache_params[:name] = @content.name_field_value unless [AttributeCategory, AttributeField].include?(@content.class)
cache_params[:universe] = @content.universe_field_value if self.respond_to?(:universe_id)
@content.update(cache_params) if cache_params.any?
if params.key? 'image_uploads'
upload_files params['image_uploads'], content_type.name, @content.id
end
update_page_tags if @content.respond_to?(:page_tags)
if content_params.key?('document_entity_id')
document_entity = DocumentEntity.find_by(id: content_params['document_entity_id'].to_i)
if document_entity.document_owner == current_user
document_entity.update(entity_id: @content.reload.id)
end
end
# If the user doesn't have this content type enabled, go ahead and automatically enable it for them
current_user.user_content_type_activators.find_or_create_by(content_type: content_type.name)
successful_response(content_creation_redirect_url, t(:create_success, model_name: @content.try(:name).presence || humanized_model_name))
else
failed_response('new', :unprocessable_entity, "Unable to save page. Error code: " + @content.errors.to_json.to_s)
end
end
def update
# TODO: most things are stripped out now that we're using per-field updates, so we should
# audit what's left of this function and what needs to stay
content_type = content_type_from_controller(self.class)
@content = content_type.with_deleted.find(params[:id])
unless @content.updatable_by?(current_user)
flash[:notice] = "You don't have permission to edit that!"
return redirect_back fallback_location: @content
end
if params.key?('image_uploads')
upload_files(params['image_uploads'], content_type.name, @content.id)
end
if @content.is_a?(Universe) && params.key?('contributors') && @content.user == current_user
params[:contributors][:email].reject(&:blank?).each do |email|
ContributorService.invite_contributor_to_universe(universe: @content, email: email.downcase)
end
end
# update_page_tags if @content.respond_to?(:page_tags)
if @content.user == current_user
# todo this needs some extra validation probably to ensure each attribute is one associated with this page
update_success = @content.reload.update(content_params)
else
# Exclude fields only the real owner can edit
#todo move field list somewhere when it grows
update_success = @content.update(content_params.except(:universe_id))
end
cache_params = {}
# TODO strip relevant logic out to AttributeCategory#update and Attribute#update so we don't need this weird branch
cache_params[:name] = @content.name_field_value unless [AttributeCategory, Attribute].include?(@content.class)
cache_params[:universe] = @content.universe_field_value if self.respond_to?(:universe_id)
@content.update(cache_params) if cache_params.any? && update_success
if update_success
successful_response(@content, t(:update_success, model_name: @content.try(:name).presence || humanized_model_name))
else
failed_response('edit', :unprocessable_entity, "Unable to save page. Error code: " + @content.errors.to_json)
end
end
def toggle_favorite
content_type = content_type_from_controller(self.class)
@content = content_type.with_deleted.find(params[:id])
unless @content.updatable_by?(current_user)
flash[:notice] = "You don't have permission to edit that!"
return redirect_back fallback_location: @content
end
@content.update!(favorite: !@content.favorite)
end
def toggle_archive
# todo Since this method is triggered via a GET in floating_action_buttons, a malicious user could technically archive
# another user's content if they're able to send that user to a specifically-crafted URL or inject that URL somewhere on
# a page (e.g. img src="/characters/1234/toggle_archive"). Since archiving is reversible this seems fine for release, but
# is something that should be fixed asap before any abuse happens.
content_type = content_type_from_controller(self.class)
@content = content_type.with_deleted.find(params[:id])
unless @content.updatable_by?(current_user)
flash[:notice] = "You don't have permission to edit that!"
return redirect_back fallback_location: @content
end
verb = nil
success = if @content.archived?
verb = "unarchived"
@content.unarchive!
else
verb = "archived"
@content.archive!
end
if success
redirect_back(fallback_location: archive_path, notice: "This page has been #{verb}.")
else
redirect_back(fallback_location: root_path, notice: "Something went wrong while attempting to archive that page.")
end
end
def changelog
content_type = content_type_from_controller(self.class)
return redirect_to root_path unless valid_content_types.include?(content_type.name)
@content = content_type.find_by(id: params[:id])
return redirect_to(root_path, notice: "You don't have permission to view that content.") if @content.nil?
@serialized_content = ContentSerializer.new(@content)
return redirect_to(root_path, notice: "You don't have permission to view that content.") unless @content.updatable_by?(current_user || User.new)
if user_signed_in?
@navbar_actions << {
label: @serialized_content.name,
href: main_app.polymorphic_path(@content)
}
@navbar_actions << {
label: 'Changelog',
href: send("changelog_#{content_type.name.downcase}_path", @content)
}
end
end
def upload_files image_uploads_list, content_type, content_id
image_uploads_list.each do |image_data|
image_size_kb = File.size(image_data.tempfile.path) / 1000.0
if current_user.upload_bandwidth_kb < image_size_kb
flash[:alert] = [
"At least one of your images failed to upload because you do not have enough upload bandwidth.",
"<a href='#{subscription_path}' class='btn white black-text center-align'>Get more</a>"
].map { |p| "<p>#{p}</p>" }.join
next
else
current_user.update(upload_bandwidth_kb: current_user.upload_bandwidth_kb - image_size_kb)
end
related_image = ImageUpload.create(
user: current_user,
content_type: content_type,
content_id: content_id,
src: image_data,
privacy: 'public'
)
end
end
def destroy
content_type = content_type_from_controller(self.class)
@content = content_type.find_by(id: params[:id])
unless current_user.can_delete?(@content)
return redirect_to :back, notice: "You don't have permission to do that!"
end
cached_page_name = @content.try(:name)
@content.destroy
successful_response(content_deletion_redirect_url, t(:delete_success, model_name: cached_page_name.presence || humanized_model_name))
end
# List all recently-deleted content
def deleted
@maximum_recovery_time = current_user.on_premium_plan? ? 7.days : 48.hours
@content_pages = {}
@activated_content_types.each do |content_type|
@content_pages[content_type] = content_type.constantize
.with_deleted
.where('deleted_at > ?', @maximum_recovery_time.ago)
.where(user_id: current_user.id)
end
@content_pages["Document"] = current_user.documents
.with_deleted
.where('documents.deleted_at > ?', @maximum_recovery_time.ago)
.includes(:user)
# Override controller
@sidenav_expansion = 'my account'
end
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]
layout 'tailwind'
def attributes
@attribute_categories = @content_type_class
.attribute_categories(current_user, show_hidden: true)
@ -468,468 +14,73 @@ class ContentController < ApplicationController
@dummy_model = @content_type_class.new
end
def attributes_tailwind
@attribute_categories = @content_type_class
.attribute_categories(current_user, show_hidden: true)
.shown_on_template_editor
.order(:position)
def gallery
content_type = content_type_from_controller(self.class)
@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?
@dummy_model = @content_type_class.new
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))
# Use the Tailwind layout
render :attributes_tailwind
end
def export_template
service = TemplateExportService.new(current_user, @content_type)
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'
case params[:format]
when 'yml', 'yaml'
send_data service.export_as_yaml,
filename: "#{@content_type}_template.yml",
type: 'text/plain'
when 'md', 'markdown'
send_data service.export_as_markdown,
filename: "#{@content_type}_template.md",
type: 'text/plain'
else
return redirect_to root_path, notice: "You don't have permission to view that content."
redirect_back fallback_location: root_path, alert: 'Invalid export format'
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 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
def set_content_type
@content_type = params[:content_type]
end
def set_attributes_content_type
@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
# Find the content type from the parameter
@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)
return unless valid_content_types.include?(entity_page_type)
@entity = entity_page_type.constantize.find_by(id: entity_page_id)
end
# For index, new, edit
# def set_general_navbar_actions
# content_type = @content_type_class || content_type_from_controller(self.class)
# return if [AttributeCategory, AttributeField, Attribute].include?(content_type)
# @navbar_actions = []
# if @current_user_content
# @navbar_actions << {
# label: "Your #{view_context.pluralize @current_user_content.fetch(content_type.name, []).count, content_type.name.downcase}",
# href: main_app.polymorphic_path(content_type)
# }
# end
# @navbar_actions << {
# label: "New #{content_type.name.downcase}",
# href: main_app.new_polymorphic_path(content_type),
# class: 'right'
# } if user_signed_in? && current_user.can_create?(content_type) \
# || PermissionService.user_has_active_promotion_for_this_content_type(user: current_user, content_type: content_type.name)
# discussions_link = ForumsLinkbuilderService.worldbuilding_url(content_type)
# if discussions_link.present?
# @navbar_actions << {
# label: 'Discussions',
# href: discussions_link
# }
# end
# # @navbar_actions << {
# # label: 'Customize template',
# # class: 'right',
# # href: main_app.attribute_customization_path(content_type.name.downcase)
# # }
# 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)
}
unless Rails.application.config.content_type_names[:all].include?(@content_type_class.name)
raise "Invalid content type: #{@content_type}"
end
end
def set_sidenav_expansion
@sidenav_expansion = 'worldbuilding'
def toggle_image_pin
# Method stub for route
end
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
end
end

View File

@ -19,6 +19,10 @@ import "../application.css";
import 'controllers'
import '../page_name_loader'
// Import Rails UJS for remote forms and CSRF tokens
import Rails from '@rails/ujs'
Rails.start()
// Support component names relative to this directory:
var componentRequireContext = require.context("components", true);
var ReactRailsUJS = require("react_ujs");

View File

@ -0,0 +1,250 @@
// 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;
});
}
};
};
// Initialize sortable for categories
initSortables();
// Show category form
document.addEventListener('click', function(event) {
if (event.target.closest('[data-action="click->attributes-editor#showAddCategoryForm"]')) {
const form = document.getElementById('add-category-form');
form.classList.toggle('hidden');
}
});
// 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);
});
// 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);
});
// Category suggestions
document.querySelectorAll('.js-show-category-suggestions').forEach(button => {
button.addEventListener('click', function(e) {
e.preventDefault();
const contentType = document.querySelector('.attributes-editor').dataset.contentType;
const resultContainer = this.closest('div').querySelector('.suggest-categories-container');
fetch(`/api/v1/categories/suggest/${contentType}`)
.then(response => response.json())
.then(data => {
const existingCategories = Array.from(document.querySelectorAll('.category-label')).map(el => el.textContent.trim());
const newCategories = data.filter(c => !existingCategories.includes(c));
if (newCategories.length > 0) {
resultContainer.innerHTML = '';
newCategories.forEach(category => {
const chip = document.createElement('span');
chip.className = 'category-suggestion-link px-3 py-1 bg-gray-100 text-sm text-gray-800 rounded-full hover:bg-gray-200 cursor-pointer';
chip.textContent = category;
chip.addEventListener('click', function() {
document.querySelector('.js-category-input').value = category;
});
resultContainer.appendChild(chip);
});
} else {
resultContainer.innerHTML = '<p class="text-sm text-gray-500">No suggestions available at the moment.</p>';
}
});
this.style.display = 'none';
});
});
// Field suggestions
document.querySelectorAll('.js-show-field-suggestions').forEach(button => {
button.addEventListener('click', function(e) {
e.preventDefault();
const contentType = document.querySelector('.attributes-editor').dataset.contentType;
const categoryContainer = this.closest('li') || this.closest('.category-card');
const categoryLabel = categoryContainer.querySelector('.category-label').textContent.trim();
const resultContainer = this.closest('div').querySelector('.suggest-fields-container');
fetch(`/api/v1/fields/suggest/${contentType}/${categoryLabel}`)
.then(response => response.json())
.then(data => {
const existingFields = Array.from(categoryContainer.querySelectorAll('.field-label')).map(el => el.textContent.trim());
const newFields = data.filter(f => !existingFields.includes(f));
if (newFields.length > 0) {
resultContainer.innerHTML = '';
newFields.forEach(field => {
const chip = document.createElement('span');
chip.className = 'field-suggestion-link px-3 py-1 bg-gray-100 text-sm text-gray-800 rounded-full hover:bg-gray-200 cursor-pointer';
chip.textContent = field;
chip.addEventListener('click', function() {
categoryContainer.querySelector('.js-field-input').value = field;
});
resultContainer.appendChild(chip);
});
} else {
resultContainer.innerHTML = '<p class="text-sm text-gray-500">No suggestions available at the moment.</p>';
}
});
this.style.display = 'none';
});
});
});
// Initialize sortable functionality using jQuery UI
function initSortables() {
if (typeof $ === 'undefined' || !$.fn.sortable) {
console.error('jQuery UI Sortable not found');
return;
}
// Categories sorting
$('#categories-container').sortable({
items: '.category-card',
handle: '.category-drag-handle',
placeholder: 'category-placeholder',
cursor: 'move',
opacity: 0.8,
tolerance: 'pointer',
update: function(event, ui) {
const categoryId = ui.item.attr('data-category-id');
const newPosition = ui.item.index();
// AJAX request to update position
$.ajax({
url: '/api/v1/content/sort',
type: 'PUT',
contentType: 'application/json',
data: JSON.stringify({
sortable_class: 'AttributeCategory',
content_id: categoryId,
intended_position: newPosition
}),
success: function(data) {
console.log('Category position updated successfully');
},
error: function(xhr, status, error) {
console.error('Error updating category position:', error);
showErrorMessage('Failed to reorder categories. Please try again.');
}
});
}
});
// Fields sorting for each category
$('.fields-container').sortable({
items: '.field-item',
handle: '.field-drag-handle',
placeholder: 'field-placeholder',
cursor: 'move',
opacity: 0.8,
tolerance: 'pointer',
connectWith: '.fields-container',
update: function(event, ui) {
const fieldId = ui.item.attr('data-field-id');
const newPosition = ui.item.index();
const categoryId = ui.item.closest('.fields-container').attr('data-category-id');
// AJAX request to update position
$.ajax({
url: '/api/v1/content/sort',
type: 'PUT',
contentType: 'application/json',
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');
},
error: function(xhr, status, error) {
console.error('Error updating field position:', error);
showErrorMessage('Failed to reorder fields. Please try again.');
}
});
}
});
}
// 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);
}
}, 5000);
}
}
// Detect screen size changes to adjust UI
window.addEventListener('resize', function() {
const alpine = Alpine.getRoot(document.querySelector('.attributes-editor'));
if (alpine && alpine.$data) {
if (window.innerWidth >= 768) {
alpine.$data.activePanel = 'both';
} else if (alpine.$data.configuring) {
alpine.$data.activePanel = 'config';
} else {
alpine.$data.activePanel = 'template';
}
}
});

View File

@ -0,0 +1,233 @@
class TemplateExportService
def initialize(user, content_type)
@user = user
@content_type = content_type.downcase
@content_type_class = @content_type.titleize.constantize
@categories = load_template_structure
end
def export_as_yaml
template_data = build_template_data
# Generate YAML with comments and metadata
yaml_content = []
yaml_content << "# #{@content_type.titleize} Template Export"
yaml_content << "# Generated: #{Time.current.strftime('%Y-%m-%d %H:%M:%S UTC')}"
yaml_content << "# Content Type: #{@content_type.titleize}"
yaml_content << "# Categories: #{template_data[:statistics][:total_categories]} | Fields: #{template_data[:statistics][:total_fields]} | User: #{@user.username || @user.email}"
yaml_content << ""
yaml_content << template_data.to_yaml
yaml_content.join("\n")
end
def export_as_markdown
template_data = build_template_data
markdown_content = []
markdown_content << "# #{@content_type.titleize} Template"
markdown_content << ""
markdown_content << "**Generated:** #{Time.current.strftime('%B %d, %Y at %H:%M UTC')}"
markdown_content << "**Content Type:** #{@content_type.titleize}"
markdown_content << "**Categories:** #{template_data[:statistics][:total_categories]} | **Fields:** #{template_data[:statistics][:total_fields]}"
markdown_content << ""
# Template overview
markdown_content << "## Template Overview"
markdown_content << ""
markdown_content << "This template defines the structure and fields for your #{@content_type.titleize.downcase} pages."
markdown_content << ""
# Statistics
stats = template_data[:statistics]
markdown_content << "### Statistics"
markdown_content << ""
markdown_content << "- **Total Categories:** #{stats[:total_categories]}"
markdown_content << "- **Total Fields:** #{stats[:total_fields]}"
markdown_content << "- **Hidden Categories:** #{stats[:hidden_categories]}"
markdown_content << "- **Hidden Fields:** #{stats[:hidden_fields]}"
markdown_content << "- **Custom Categories:** #{stats[:custom_categories]}"
markdown_content << ""
# Categories and fields
markdown_content << "## Template Structure"
markdown_content << ""
template_data[:template][:categories].each do |category_name, category_data|
icon_display = category_data[:icon] ? " 📋" : ""
hidden_display = category_data[:hidden] ? " (Hidden)" : ""
markdown_content << "### #{category_data[:label]}#{icon_display}#{hidden_display}"
markdown_content << ""
if category_data[:description].present?
markdown_content << "_#{category_data[:description]}_"
markdown_content << ""
end
if category_data[:fields].any?
category_data[:fields].each do |field_name, field_data|
field_icon = case field_data[:field_type]
when 'name' then '📝'
when 'text_area' then '📄'
when 'link' then '🔗'
when 'universe' then '🌍'
when 'tags' then '🏷️'
else '📋'
end
hidden_text = field_data[:hidden] ? " _(Hidden)_" : ""
markdown_content << "- **#{field_data[:label]}** #{field_icon}#{hidden_text}"
if field_data[:description].present?
markdown_content << " - _#{field_data[:description]}_"
end
if field_data[:field_type] == 'link' && field_data[:field_options][:linkable_types].present?
linkable_types = field_data[:field_options][:linkable_types].join(', ')
markdown_content << " - Links to: #{linkable_types}"
end
end
markdown_content << ""
else
markdown_content << "_No fields in this category_"
markdown_content << ""
end
end
# Customizations
if template_data[:customizations].any?
markdown_content << "## Template Customizations"
markdown_content << ""
markdown_content << "The following customizations have been made from the default template:"
markdown_content << ""
template_data[:customizations].each do |customization|
case customization[:action]
when 'added_category'
markdown_content << "- **Added Category:** #{customization[:label]}"
when 'modified_field'
markdown_content << "- ✏️ **Modified Field:** #{customization[:category]}#{customization[:field]} (#{customization[:change]})"
when 'hidden_category'
markdown_content << "- 👁️ **Hidden Category:** #{customization[:label]}"
when 'hidden_field'
markdown_content << "- 👁️ **Hidden Field:** #{customization[:category]}#{customization[:field]}"
end
end
markdown_content << ""
end
markdown_content << "---"
markdown_content << "_Template exported from Notebook.ai on #{Time.current.strftime('%B %d, %Y')}_"
markdown_content.join("\n")
end
private
def load_template_structure
@content_type_class
.attribute_categories(@user, show_hidden: true)
.shown_on_template_editor
.includes(:attribute_fields)
.order(:position)
end
def build_template_data
template_categories = {}
total_fields = 0
hidden_categories = 0
hidden_fields = 0
custom_categories = 0
customizations = []
# Load default template structure for comparison
default_structure = load_default_template_structure
@categories.each do |category|
total_fields += category.attribute_fields.count
hidden_categories += 1 if category.hidden?
# Check if this is a custom category (not in defaults)
unless default_structure.key?(category.name.to_sym)
custom_categories += 1
customizations << {
action: 'added_category',
name: category.name,
label: category.label
}
end
# Track hidden categories
if category.hidden?
customizations << {
action: 'hidden_category',
name: category.name,
label: category.label
}
end
# Build category data
category_fields = {}
category.attribute_fields.order(:position).each do |field|
hidden_fields += 1 if field.hidden?
# Track hidden fields
if field.hidden?
customizations << {
action: 'hidden_field',
category: category.label,
field: field.label
}
end
category_fields[field.name.to_sym] = {
label: field.label,
field_type: field.field_type,
position: field.position,
description: field.description,
hidden: field.hidden?,
field_options: field.field_options || {}
}
end
template_categories[category.name.to_sym] = {
label: category.label,
icon: category.icon,
description: category.description,
position: category.position,
hidden: category.hidden?,
fields: category_fields
}
end
{
template: {
content_type: @content_type,
icon: @content_type_class.icon,
categories: template_categories
},
statistics: {
total_categories: @categories.count,
total_fields: total_fields,
hidden_categories: hidden_categories,
hidden_fields: hidden_fields,
custom_categories: custom_categories
},
customizations: customizations
}
end
def load_default_template_structure
# Load the default YAML structure for comparison
yaml_path = Rails.root.join('config', 'attributes', "#{@content_type}.yml")
if File.exist?(yaml_path)
YAML.load_file(yaml_path) || {}
else
{}
end
rescue => e
Rails.logger.warn "Could not load default template structure for #{@content_type}: #{e.message}"
{}
end
end

View File

@ -40,6 +40,12 @@
</div>
-->
<div class="my-4 text-center">
<%= link_to attribute_customization_tailwind_path(content_type: @content_type), class: 'btn purple' do %>
<i class="material-icons left">star</i>Try the new template editor
<% end %>
</div>
<div class="attributes-editor" data-content-type="<%= @content_type %>">
<ul class="hoverable sortable collapsible white" data-sortable-class="AttributeCategory">
<%= render partial: 'content/attributes/general_settings', locals: { content_type: @content_type, content_type_class: @content_type_class } %>

View File

@ -0,0 +1,191 @@
<div class="category-card bg-white rounded-lg shadow-md overflow-hidden border-l-4 <%= category.hidden? ? 'border-gray-300' : '' %>"
data-category-id="<%= category.id %>"
data-position="<%= category.position %>"
x-data="{ isOpen: false }"
:class="{ 'ring-2 ring-opacity-70': selectedCategory == <%= category.id %> }"
:style="selectedCategory == <%= category.id %> ? '--tw-ring-color: var(--content-type-color);' : ''"
style="<%= category.hidden? ? '' : "border-color: #{content_type_class.hex_color};" %>">
<!-- Category Header -->
<div class="flex items-center p-3 cursor-pointer category-header"
@click="console.log('Category header clicked, toggling isOpen from', isOpen, 'to', !isOpen); isOpen = !isOpen; if (isOpen) { $dispatch('select-category', { id: <%= category.id %> }) }"
<%= category.hidden? ? 'style="background-color: #f9fafb;"' : "style=\"background-color: #{content_type_class.hex_color}20;\"" %>>
<!-- Drag Handle -->
<div class="category-drag-handle flex items-center justify-center p-2 mr-2 rounded hover:bg-white hover:bg-opacity-50 cursor-move"
@click.stop>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</div>
<!-- Category Icon -->
<div class="category-icon flex-shrink-0 w-8 h-8 rounded-full <%= category.hidden? ? 'bg-white' : 'bg-white' %> flex items-center justify-center mr-3 shadow-sm">
<i class="material-icons <%= category.hidden? ? 'text-gray-400' : '' %> text-lg"
<%= category.hidden? ? '' : "style=\"color: #{content_type_class.hex_color};\"" %>><%= category.icon %></i>
</div>
<!-- Category Title -->
<div class="flex-grow">
<h3 class="category-label text-gray-900 font-medium"><%= category.label %></h3>
<p class="text-gray-500 text-xs">
<%= pluralize(category.attribute_fields.count, 'field') %>
<% if category.hidden? %>
<span class="text-gray-400 ml-2">— Hidden</span>
<% end %>
</p>
</div>
<!-- Actions -->
<div class="flex items-center space-x-1">
<!-- Edit Button -->
<button class="p-1.5 rounded hover:bg-white hover:bg-opacity-50"
title="Configure Category"
@click.stop="$dispatch('select-category', { id: <%= category.id %> })">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</button>
<!-- Visibility Toggle -->
<button class="category-visibility-toggle p-1.5 rounded hover:bg-white hover:bg-opacity-50 transition-colors"
data-category-id="<%= category.id %>"
data-hidden="<%= category.hidden? %>"
title="<%= category.hidden? ? 'Hidden category - Click to show' : 'Visible category - Click to hide' %>"
@click.stop="toggleCategoryVisibility(<%= category.id %>, <%= category.hidden? %>)">
<svg class="w-4 h-4 text-gray-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<% if category.hidden? %>
<!-- Hidden state - show EYE CLOSED icon -->
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
<% else %>
<!-- Visible state - show EYE OPEN icon -->
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
<% end %>
</svg>
</button>
<!-- Expand/Collapse Button -->
<svg class="w-4 h-4 text-gray-500 transition-transform duration-200"
:class="{ 'transform rotate-180': isOpen }"
fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</div>
<!-- Category Body -->
<div x-show="isOpen"
x-transition
class="p-4 category-body"
@click.self="$dispatch('select-category', { id: <%= category.id %> })">
<!-- Fields Container -->
<div class="fields-container space-y-2" data-category-id="<%= category.id %>">
<% category.attribute_fields.order(:position).each do |field| %>
<%= render partial: 'content/attributes/tailwind/field_item', locals: {
field: field,
content_type_class: content_type_class,
content_type: content_type
} %>
<% end %>
</div>
<!-- Add Field Section -->
<div class="mt-4 pt-3 border-t border-gray-100" x-data="{ addingField: false, fieldType: 'text_area' }">
<button x-show="!addingField"
@click="addingField = true"
class="flex items-center text-sm hover:underline focus:outline-none"
style="color: <%= content_type_class.hex_color %>;">
<i class="material-icons text-sm mr-1">add_circle_outline</i>
<span>Add Field</span>
</button>
<!-- Add Field Form -->
<div x-show="addingField" class="mt-3 space-y-4">
<div class="flex space-x-2">
<button @click="fieldType = 'text_area'"
:style="fieldType === 'text_area' ? 'background-color: <%= content_type_class.hex_color %>20; border-color: <%= content_type_class.hex_color %>; color: <%= content_type_class.hex_color %>;' : ''"
:class="fieldType === 'text_area' ? '' : 'bg-white border-gray-300 text-gray-700'"
class="flex-1 py-2 px-3 border rounded-md text-sm font-medium flex justify-center items-center">
<i class="material-icons text-sm mr-1.5">text_fields</i>
Text
</button>
<button @click="fieldType = 'link'"
:style="fieldType === 'link' ? 'background-color: <%= content_type_class.hex_color %>20; border-color: <%= content_type_class.hex_color %>; color: <%= content_type_class.hex_color %>;' : ''"
:class="fieldType === 'link' ? '' : 'bg-white border-gray-300 text-gray-700'"
class="flex-1 py-2 px-3 border rounded-md text-sm font-medium flex justify-center items-center">
<i class="material-icons text-sm mr-1.5">link</i>
Link
</button>
</div>
<!-- Text Field Form -->
<div x-show="fieldType === 'text_area'">
<%= form_for(category.attribute_fields.build, method: :post, html: { class: "space-y-3", 'data-type': 'json' }, remote: true) do |f| %>
<%= f.hidden_field :attribute_category_id, value: category.id %>
<%= f.hidden_field :field_type, value: 'text_area' %>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Field Name</label>
<%= f.text_field :label, class: "shadow-sm block w-full sm:text-sm border-gray-300 rounded-md js-field-input 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};" %>
</div>
<div class="flex justify-end space-x-2">
<button type="button" @click="addingField = false" class="px-3 py-1.5 border border-gray-300 rounded-md text-xs font-medium text-gray-700 hover:bg-gray-50">
Cancel
</button>
<%= f.submit 'Add Text Field', class: "px-3 py-1.5 border border-transparent rounded-md shadow-sm text-xs font-medium text-white", style: "background-color: #{content_type_class.hex_color};" %>
</div>
<% end %>
<div class="mt-3 pt-3 border-t border-gray-200">
<div class="text-xs font-medium text-gray-700 mb-2">Field Suggestions</div>
<div class="suggest-fields-container flex flex-wrap gap-2 mb-2"></div>
<button type="button" class="js-show-field-suggestions px-3 py-1 border border-gray-300 rounded-md text-xs font-medium text-gray-700 hover:bg-gray-50">
Show Suggestions
</button>
</div>
</div>
<!-- Link Field Form -->
<div x-show="fieldType === 'link'">
<%= form_for(category.attribute_fields.build, method: :post, html: { class: "space-y-3", 'data-type': 'json' }, remote: true) do |f| %>
<%= f.hidden_field :attribute_category_id, value: category.id %>
<%= f.hidden_field :field_type, value: 'link' %>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Field Name</label>
<%= f.text_field :label, class: "shadow-sm block w-full sm:text-sm border-gray-300 rounded-md js-field-input 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};" %>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Link to Content Types</label>
<div class="border border-gray-300 rounded-md shadow-sm p-2 max-h-32 overflow-y-auto">
<% Rails.application.config.content_types[:all].each do |content_type| %>
<div class="flex items-center mb-1 last:mb-0">
<input type="checkbox" name="attribute_field[field_options][linkable_types][]"
value="<%= content_type.name %>"
id="link_type_<%= content_type.name.downcase %>_<%= category.id %>"
class="h-4 w-4 border-gray-300 rounded focus:ring-2 focus:ring-offset-2"
style="color: <%= content_type_class.hex_color %>; --tw-ring-color: <%= content_type_class.hex_color %>;"
<label for="link_type_<%= content_type.name.downcase %>_<%= category.id %>" class="ml-2 block text-sm text-gray-700">
<i class="material-icons text-xs align-middle mr-1"><%= content_type.icon %></i>
<%= content_type.name.pluralize %>
</label>
</div>
<% end %>
</div>
</div>
<div class="flex justify-end space-x-2">
<button type="button" @click="addingField = false" class="px-3 py-1.5 border border-gray-300 rounded-md text-xs font-medium text-gray-700 hover:bg-gray-50">
Cancel
</button>
<%= f.submit 'Add Link Field', class: "px-3 py-1.5 border border-transparent rounded-md shadow-sm text-xs font-medium text-white", style: "background-color: #{content_type_class.hex_color};" %>
</div>
<% end %>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,177 @@
<div class="p-4">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">Configure Category</h3>
<button @click="selectedCategory = null" class="p-2 rounded hover:bg-gray-100">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- Tab Navigation -->
<div x-data="{ activeTab: 'general' }">
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-6">
<button @click="activeTab = 'general'"
:style="activeTab === 'general' ? 'border-color: <%= content_type_class.hex_color %>; color: <%= content_type_class.hex_color %>;' : ''"
:class="activeTab === 'general' ? '' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
General
</button>
<button @click="activeTab = 'advanced'"
:style="activeTab === 'advanced' ? 'border-color: <%= content_type_class.hex_color %>; color: <%= content_type_class.hex_color %>;' : ''"
:class="activeTab === 'advanced' ? '' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
Advanced
</button>
</nav>
</div>
<!-- General Tab -->
<div x-show="activeTab === 'general'" class="py-4 space-y-4">
<%= form_for(category, url: "/plan/attribute_categories/#{category.id}", method: :put, html: {class: 'space-y-4', 'data-type': 'json'}, remote: true) do |f| %>
<%= f.hidden_field :name %>
<%= f.hidden_field :entity_type %>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Category Name</label>
<%= f.text_field :label, 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};" %>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Icon</label>
<div class="grid grid-cols-8 gap-2 max-h-80 overflow-y-auto border border-gray-200 rounded-md p-4 bg-gray-50" x-data="{ selectedIcon: '<%= category.icon %>' }">
<% MATERIAL_ICONS.each do |icon| %>
<div class="aspect-square">
<button type="button"
class="w-full h-full flex items-center justify-center rounded-md transition-colors border"
:class="selectedIcon === '<%= icon %>' ? 'bg-white shadow-sm' : 'hover:bg-white hover:shadow-sm bg-gray-100'"
:style="selectedIcon === '<%= icon %>' ? 'border-color: <%= content_type_class.hex_color %>; background-color: <%= content_type_class.hex_color %>10;' : 'border-color: transparent;'"
@click="selectedIcon = '<%= icon %>'; document.getElementById('attribute_category_icon').value = '<%= icon %>'; updateCategoryIconPreview(<%= category.id %>, '<%= icon %>')"
title="<%= icon.humanize %>">
<i class="material-icons text-2xl transition-colors"
:class="selectedIcon === '<%= icon %>' ? '' : 'text-gray-600'"
:style="selectedIcon === '<%= icon %>' ? 'color: <%= content_type_class.hex_color %>;' : ''"><%= icon %></i>
</button>
</div>
<% end %>
</div>
<%= f.hidden_field :icon, id: 'attribute_category_icon' %>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Display Order</label>
<%= f.number_field :position, class: "shadow-sm block w-full sm:text-sm border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color}; border-color: #{content_type_class.hex_color};" %>
<p class="mt-1 text-xs text-gray-500">Lower numbers appear first. You can also drag and drop to reorder.</p>
</div>
<div class="pt-2">
<%= f.submit 'Save Changes', class: "px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %>
</div>
<% end %>
</div>
<!-- Advanced Tab -->
<div x-show="activeTab === 'advanced'" class="py-4 space-y-4">
<div class="space-y-6">
<div>
<h6 class="text-sm font-medium text-gray-700 mb-2">Category Visibility</h6>
<div class="bg-gray-50 p-4 rounded-md border border-gray-200">
<%= form_for(category, url: "/plan/attribute_categories/#{category.id}", method: :put, html: {class: 'inline', 'data-type': 'json'}, remote: true) do |f| %>
<%= f.hidden_field :name %>
<%= f.hidden_field :entity_type %>
<%= f.hidden_field :hidden, value: !category.hidden %>
<% if category.hidden? %>
<div class="flex items-start mb-3">
<div class="flex-shrink-0">
<i class="material-icons text-gray-400">visibility_off</i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-700">This category is currently hidden</p>
<p class="text-xs text-gray-500">It won't appear on content pages until you show it again.</p>
</div>
</div>
<%= f.submit 'Show this category', class: "px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color};" %>
<% else %>
<div class="flex items-start mb-3">
<div class="flex-shrink-0">
<i class="material-icons text-gray-700">visibility</i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-700">This category is currently visible</p>
<p class="text-xs text-gray-500">It appears on all content pages.</p>
</div>
</div>
<%= f.submit 'Hide this category', class: "px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color};" %>
<% end %>
<% end %>
</div>
</div>
<!-- Category Deletion -->
<div>
<h6 class="text-sm font-medium text-gray-700 mb-2">Danger Zone</h6>
<div class="bg-red-50 p-4 rounded-md border border-red-200">
<div class="flex items-start mb-3">
<div class="flex-shrink-0">
<i class="material-icons text-red-600">warning</i>
</div>
<div class="ml-3">
<p class="text-sm text-red-800 font-medium">Delete this category</p>
<p class="text-xs text-red-600 mt-1">
This will permanently delete the category, all <%= pluralize(category.attribute_fields.count, 'field') %> in it,
and all content data stored in these fields across your <%= content_type.titleize.downcase %> pages.
</p>
<p class="text-xs text-red-600 mt-1 font-medium">
This action cannot be undone.
</p>
</div>
</div>
<div x-data="{ confirmDelete: false }" class="space-y-3">
<div x-show="!confirmDelete">
<button @click="confirmDelete = true"
type="button"
class="px-3 py-1.5 bg-red-600 border border-red-600 rounded-md text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
Delete Category
</button>
</div>
<div x-show="confirmDelete"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
class="space-y-3">
<p class="text-sm text-red-800 font-medium">
Are you absolutely sure? Type "<strong><%= category.label %></strong>" to confirm:
</p>
<%= form_for(category, url: "/plan/attribute_categories/#{category.id}", method: :delete, html: {class: 'space-y-3', 'data-type': 'json', 'x-data': '{ confirmText: "" }'}, remote: true) do |f| %>
<input type="text"
x-model="confirmText"
placeholder="<%= category.label %>"
class="shadow-sm block w-full sm:text-sm border-red-300 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
<div class="flex justify-end space-x-2">
<button @click="confirmDelete = false"
type="button"
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
Cancel
</button>
<button type="submit"
:disabled="confirmText !== '<%= category.label %>'"
:class="confirmText === '<%= category.label %>' ? 'bg-red-600 hover:bg-red-700 text-white' : 'bg-gray-300 text-gray-500 cursor-not-allowed'"
class="px-3 py-1.5 border border-transparent rounded-md text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
<i class="material-icons text-sm mr-1">delete_forever</i>
Permanently Delete
</button>
</div>
<% end %>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,253 @@
<div class="p-4">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">Configure Field</h3>
<button @click="selectedField = null" class="p-2 rounded hover:bg-gray-100">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- Tab Navigation -->
<div x-data="{ activeTab: 'general' }">
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-6">
<button @click="activeTab = 'general'"
:style="activeTab === 'general' ? 'border-color: <%= content_type_class.hex_color %>; color: <%= content_type_class.hex_color %>;' : ''"
:class="activeTab === 'general' ? '' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
General
</button>
<button @click="activeTab = 'appearance'"
:style="activeTab === 'appearance' ? 'border-color: <%= content_type_class.hex_color %>; color: <%= content_type_class.hex_color %>;' : ''"
:class="activeTab === 'appearance' ? '' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
Appearance
</button>
<button @click="activeTab = 'advanced'"
:style="activeTab === 'advanced' ? 'border-color: <%= content_type_class.hex_color %>; color: <%= content_type_class.hex_color %>;' : ''"
:class="activeTab === 'advanced' ? '' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
Advanced
</button>
</nav>
</div>
<!-- General Tab -->
<div x-show="activeTab === 'general'" class="py-4 space-y-4">
<%= form_for(field, method: :put, html: {class: 'space-y-4', 'data-type': 'json'}, remote: true) do |f| %>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Field Label</label>
<%= f.text_field :label, 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};" %>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Field Type</label>
<div class="flex items-center space-x-2">
<div class="w-6 h-6 rounded bg-gray-100 flex items-center justify-center">
<% if field.field_type == 'text_area' %>
<i class="material-icons text-gray-600 text-sm">text_fields</i>
<% elsif field.field_type == 'link' %>
<i class="material-icons text-blue-600 text-sm">link</i>
<% elsif field.field_type == 'name' %>
<i class="material-icons text-purple-600 text-sm">label</i>
<% elsif field.field_type == 'universe' %>
<i class="material-icons text-indigo-600 text-sm">language</i>
<% elsif field.field_type == 'tags' %>
<i class="material-icons text-green-600 text-sm">local_offer</i>
<% else %>
<i class="material-icons text-gray-600 text-sm">subject</i>
<% end %>
</div>
<span class="text-sm text-gray-700">
<%= field.field_type.humanize %>
<% if field.name_field? || field.universe_field? || field.tags_field? %>
(Cannot be changed)
<% end %>
</span>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Display Order</label>
<%= f.number_field :position, class: "shadow-sm block w-full sm:text-sm border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color}; border-color: #{content_type_class.hex_color};" %>
<p class="mt-1 text-xs text-gray-500">Lower numbers appear first. You can also drag and drop to reorder.</p>
</div>
<div class="pt-2">
<%= f.submit 'Save Changes', class: "px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %>
</div>
<% end %>
</div>
<!-- Appearance Tab -->
<div x-show="activeTab === 'appearance'" class="py-4 space-y-4">
<div class="space-y-4">
<% if field.field_type == 'text_area' %>
<div x-data="{ selectedSize: 'medium' }">
<label class="block text-sm font-medium text-gray-700 mb-1">Input Size</label>
<select x-model="selectedSize" class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 sm:text-sm rounded-md" style="--tw-ring-color: <%= content_type_class.hex_color %>; border-color: <%= content_type_class.hex_color %>;">
<option value="small">Small (one line)</option>
<option value="medium">Medium (multiple lines)</option>
<option value="large">Large (rich text)</option>
</select>
<!-- Preview Section -->
<div class="mt-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Preview</label>
<div class="border border-gray-200 rounded-md p-3 bg-gray-50">
<!-- Small input preview -->
<input x-show="selectedSize === 'small'"
type="text"
placeholder="This is how the small input will look..."
disabled
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white text-gray-500 cursor-not-allowed">
<!-- Medium textarea preview -->
<textarea x-show="selectedSize === 'medium'"
placeholder="This is how the medium textarea will look...&#10;It spans multiple lines&#10;Perfect for longer descriptions"
disabled
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white text-gray-500 cursor-not-allowed resize-none"></textarea>
<!-- Large textarea preview -->
<textarea x-show="selectedSize === 'large'"
placeholder="This is how the large textarea will look...&#10;It has more vertical space&#10;Ideal for rich text content&#10;Longer descriptions&#10;Multiple paragraphs&#10;And more detailed information"
disabled
rows="6"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white text-gray-500 cursor-not-allowed resize-none"></textarea>
</div>
</div>
</div>
<% elsif field.field_type == 'link' %>
<%= form_for(field, method: :put, html: {class: 'space-y-4', 'data-type': 'json', id: 'linkable-types-form'}, remote: true) do |f| %>
<div>
<div class="flex items-center justify-between mb-3">
<label class="block text-sm font-medium text-gray-700">Linkable Types</label>
<div class="flex space-x-2">
<button type="button"
onclick="selectAllLinkableTypes()"
class="px-2 py-1 text-xs font-medium text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded transition-colors">
Select All
</button>
<button type="button"
onclick="selectNoneLinkableTypes()"
class="px-2 py-1 text-xs font-medium text-gray-600 hover:text-gray-700 hover:bg-gray-50 rounded transition-colors">
Select None
</button>
</div>
</div>
<p class="text-sm text-gray-500 mb-4">Select which content types users can link to from this field.</p>
<!-- Hidden field to ensure linkable_types parameter is always sent -->
<input type="hidden" name="attribute_field[field_options][linkable_types][]" value="" />
<div class="space-y-2 max-h-64 overflow-y-auto border border-gray-200 rounded-md p-3 bg-gray-50">
<% Rails.application.config.content_types[:all].each do |content_type| %>
<label class="flex items-center p-3 bg-white rounded-md border border-gray-200 hover:border-gray-300 hover:bg-gray-50 cursor-pointer transition-colors">
<input type="checkbox"
name="attribute_field[field_options][linkable_types][]"
value="<%= content_type.name %>"
<%= 'checked' if field.field_options&.dig('linkable_types')&.include?(content_type.name) %>
class="h-4 w-4 border-gray-300 rounded focus:ring-2 focus:ring-offset-2 mr-3"
style="color: <%= content_type_class.hex_color %>; --tw-ring-color: <%= content_type_class.hex_color %>;">
<div class="flex items-center flex-grow">
<div class="w-8 h-8 rounded-full flex items-center justify-center mr-3"
style="background-color: <%= content_type.hex_color %>20; color: <%= content_type.hex_color %>;">
<i class="material-icons text-sm"><%= content_type.icon %></i>
</div>
<div class="flex-grow">
<div class="text-sm font-medium text-gray-900">
<%= content_type.name.pluralize %>
</div>
</div>
</div>
</label>
<% end %>
</div>
<div class="pt-4">
<%= f.submit 'Save Linkable Types', class: "px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %>
</div>
</div>
<% end %>
<% end %>
</div>
</div>
<!-- Advanced Tab -->
<div x-show="activeTab === 'advanced'" class="py-4 space-y-4">
<div class="space-y-6">
<div>
<h6 class="text-sm font-medium text-gray-700 mb-2">Field Visibility</h6>
<div class="bg-gray-50 p-4 rounded-md border border-gray-200">
<%= 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? %>
<div class="flex items-start mb-3">
<div class="flex-shrink-0">
<i class="material-icons text-gray-400">visibility_off</i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-700">This field is currently hidden</p>
<p class="text-xs text-gray-500">It won't appear on content pages until you show it again.</p>
</div>
</div>
<%= f.submit 'Show this field', class: "px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color};" %>
<% else %>
<div class="flex items-start mb-3">
<div class="flex-shrink-0">
<i class="material-icons text-gray-700">visibility</i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-700">This field is currently visible</p>
<p class="text-xs text-gray-500">It appears on all content pages.</p>
</div>
</div>
<%= f.submit 'Hide this field', class: "px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color};" %>
<% end %>
<% end %>
</div>
</div>
<div>
<h6 class="text-sm font-medium text-gray-700 mb-2">Privacy Settings</h6>
<div class="bg-gray-50 p-4 rounded-md border border-gray-200">
<div class="flex items-center space-x-2 mb-3">
<input type="checkbox"
<%= "checked" if field.label.start_with?('Private') %>
id="field_private"
class="h-4 w-4 border-gray-300 rounded focus:ring-2 focus:ring-offset-2"
style="color: <%= content_type_class.hex_color %>; --tw-ring-color: <%= content_type_class.hex_color %>;">
<label for="field_private" class="text-sm text-gray-700">Mark as private field</label>
</div>
<p class="text-xs text-gray-500">Private fields are only visible to you, even when sharing content publicly.</p>
</div>
</div>
<% unless field.name_field? || field.universe_field? || field.tags_field? %>
<div>
<h6 class="text-sm font-medium text-red-700 mb-2">Danger Zone</h6>
<div class="bg-red-50 p-4 rounded-md border border-red-200">
<p class="text-sm text-red-700 mb-3">
Deleting a field will permanently remove it and all its data from all <%= content_type.pluralize.downcase %>.
</p>
<div class="flex justify-end">
<%= link_to "Delete Field",
field,
class: 'px-3 py-1.5 border border-transparent rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500',
method: :delete,
data: {
confirm: "Are you sure? This will delete this field AND all of its answers to EVERY page you've created. This action cannot be undone!"
} %>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,99 @@
<div class="field-item bg-white border rounded-md shadow-sm p-2.5 flex items-center cursor-pointer hover:bg-gray-50 <%= field.hidden? ? 'bg-gray-50 border-gray-200' : '' %>"
data-field-id="<%= field.id %>"
data-position="<%= field.position %>"
:class="{ 'ring-2 ring-opacity-70': selectedField == <%= field.id %> }"
:style="selectedField == <%= field.id %> ? '--tw-ring-color: var(--content-type-color);' : ''"
@click="console.log('Field clicked:', <%= field.id %>); $dispatch('select-field', { id: <%= field.id %> })">
<!-- Drag Handle -->
<div class="field-drag-handle p-1.5 mr-2 rounded hover:bg-gray-100 cursor-move" @click.stop>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l4-4 4 4m0 6l-4 4-4-4"></path>
</svg>
</div>
<!-- Field Type Icon -->
<div class="field-type-icon flex-shrink-0 w-6 h-6 rounded bg-gray-100 flex items-center justify-center mr-2.5">
<% if field.field_type == 'text_area' %>
<i class="material-icons text-gray-600 text-sm">text_fields</i>
<% elsif field.field_type == 'link' %>
<i class="material-icons text-blue-600 text-sm">link</i>
<% elsif field.field_type == 'name' %>
<i class="material-icons text-purple-600 text-sm">label</i>
<% elsif field.field_type == 'universe' %>
<i class="material-icons text-indigo-600 text-sm">language</i>
<% elsif field.field_type == 'tags' %>
<i class="material-icons text-green-600 text-sm">local_offer</i>
<% else %>
<i class="material-icons text-gray-600 text-sm">subject</i>
<% end %>
</div>
<!-- Field Info -->
<div class="flex-grow min-w-0">
<div class="field-label font-medium text-sm text-gray-800 truncate <%= field.hidden? ? 'text-gray-500' : '' %>">
<%= field.label %>
</div>
<div class="text-xs text-gray-500">
<% if field.name_field? %>
Name field
<% elsif field.universe_field? %>
Universe field
<% elsif field.tags_field? %>
Tags field
<% elsif field.field_type == 'link' %>
Link field
<% else %>
Text field
<% end %>
<% if field.hidden? %>
<span class="ml-1.5 text-gray-400">— Hidden</span>
<% end %>
<% if field.label.start_with?('Private') %>
<span class="ml-1.5 text-gray-400">— Private</span>
<% end %>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center space-x-1.5">
<!-- Configure Button -->
<button class="p-1.5 rounded hover:bg-gray-100"
title="Configure Field"
@click.stop="console.log('Cog clicked for field:', <%= field.id %>); $dispatch('select-field', { id: <%= field.id %> })">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</button>
<!-- Visibility Button -->
<% unless field.name_field? || field.universe_field? || field.tags_field? %>
<button class="field-visibility-toggle p-1.5 rounded hover:bg-gray-100 transition-colors"
data-field-id="<%= field.id %>"
data-hidden="<%= field.hidden? %>"
title="<%= field.hidden? ? 'Hidden field - Click to show' : 'Visible field - Click to hide' %>"
@click.stop="toggleFieldVisibility(<%= field.id %>, <%= field.hidden? %>)">
<svg class="w-4 h-4 text-gray-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<% if field.hidden? %>
<!-- Hidden state - show EYE CLOSED icon -->
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
<% else %>
<!-- Visible state - show EYE OPEN icon -->
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
<% end %>
</svg>
</button>
<!-- Delete Button -->
<button class="p-1.5 rounded hover:bg-gray-100 hover:text-red-500"
title="Delete Field">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
<% end %>
</div>
</div>

View File

@ -0,0 +1,148 @@
<div class="p-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-medium text-gray-900 flex items-center">
<i class="material-icons text-gray-600 mr-2">settings</i>
General Settings
</h2>
<button @click="configuring = false; selectedCategory = null; selectedField = null;"
class="p-1.5 rounded hover:bg-gray-100"
title="Close Settings">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="space-y-6">
<!-- Template Information -->
<div class="bg-gray-50 rounded-lg p-4">
<h3 class="font-medium text-gray-900 mb-2 flex items-center">
<i class="material-icons text-gray-600 mr-2 text-lg"><%= content_type_class.icon %></i>
<%= content_type.titleize %> Template
</h3>
<p class="text-sm text-gray-600 mb-3">
This template defines the structure and fields for your <%= content_type.titleize.downcase %> pages.
</p>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Categories:</span>
<span class="ml-1 font-medium"><%= @attribute_categories.count %></span>
</div>
<div>
<span class="text-gray-500">Total Fields:</span>
<span class="ml-1 font-medium"><%= @attribute_categories.sum { |cat| cat.attribute_fields.count } %></span>
</div>
</div>
</div>
<!-- Template Actions -->
<div>
<h3 class="font-medium text-gray-900 mb-3">Template Actions</h3>
<div class="space-y-2">
<div class="border border-gray-200 rounded-lg overflow-hidden" x-data="{ exportOpen: false }">
<button @click="exportOpen = !exportOpen"
class="w-full flex items-center justify-between p-3 hover:bg-gray-50 transition-colors text-left">
<div class="flex items-center">
<i class="material-icons text-gray-600 mr-3">file_download</i>
<div>
<div class="font-medium text-gray-900">Export Template</div>
<div class="text-sm text-gray-500">Download this template configuration</div>
</div>
</div>
<i class="material-icons text-gray-400 transition-transform"
:class="{ 'rotate-180': exportOpen }">expand_more</i>
</button>
<div x-show="exportOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
class="border-t border-gray-200 bg-gray-50 p-3">
<div class="flex space-x-2">
<a href="<%= export_template_path(content_type: content_type, format: 'yml') %>"
class="flex-1 px-3 py-2 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 text-center transition-colors">
<i class="material-icons text-xs mr-1">code</i>
YAML
</a>
<a href="<%= export_template_path(content_type: content_type, format: 'md') %>"
class="flex-1 px-3 py-2 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 text-center transition-colors">
<i class="material-icons text-xs mr-1">description</i>
Markdown
</a>
</div>
<p class="text-xs text-gray-500 mt-2">
YAML for technical users, Markdown for documentation
</p>
</div>
</div>
<button class="w-full flex items-center justify-between p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-left">
<div class="flex items-center">
<i class="material-icons text-gray-600 mr-3">file_upload</i>
<div>
<div class="font-medium text-gray-900">Import Template</div>
<div class="text-sm text-gray-500">Load a saved template configuration</div>
</div>
</div>
<i class="material-icons text-gray-400">chevron_right</i>
</button>
<button class="w-full flex items-center justify-between p-3 border border-red-200 rounded-lg hover:bg-red-50 transition-colors text-left">
<div class="flex items-center">
<i class="material-icons text-red-600 mr-3">restore</i>
<div>
<div class="font-medium text-red-900">Reset Template</div>
<div class="text-sm text-red-600">Delete all customizations and start fresh</div>
</div>
</div>
<i class="material-icons text-red-400">chevron_right</i>
</button>
</div>
</div>
<!-- Template Settings -->
<div>
<h3 class="font-medium text-gray-900 mb-3">Display Settings</h3>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<div class="font-medium text-gray-900">Show field descriptions</div>
<div class="text-sm text-gray-500">Display help text for each field</div>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" checked>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600" style="--tw-ring-color: var(--content-type-color); --peer-checked-bg: var(--content-type-color);"></div>
</label>
</div>
<div class="flex items-center justify-between">
<div>
<div class="font-medium text-gray-900">Compact field layout</div>
<div class="text-sm text-gray-500">Use smaller spacing between fields</div>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600" style="--tw-ring-color: var(--content-type-color); --peer-checked-bg: var(--content-type-color);"></div>
</label>
</div>
</div>
</div>
<!-- Help Section -->
<div class="bg-blue-50 rounded-lg p-4">
<h3 class="font-medium text-blue-900 mb-2 flex items-center">
<i class="material-icons text-blue-600 mr-2">help_outline</i>
Need Help?
</h3>
<p class="text-sm text-blue-700 mb-3">
Learn more about customizing your templates and organizing your content.
</p>
<a href="#" class="text-sm font-medium text-blue-600 hover:text-blue-500 flex items-center">
View Documentation
<i class="material-icons text-sm ml-1">arrow_forward</i>
</a>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -10,8 +10,11 @@ Rails.application.routes.draw do
namespace :api do
namespace :v1 do
post 'gallery_images/sort'
get 'categories/suggest/:content_type', to: 'categories#suggest'
get 'fields/suggest/:content_type/:category', to: 'fields#suggest'
put 'content/sort', to: 'content#sort'
get 'categories/suggest/:content_type', to: 'attribute_categories#suggest'
get 'categories/:id/edit', to: 'attribute_categories#edit'
get 'fields/suggest/:content_type/:category', to: 'attribute_fields#suggest'
get 'fields/:id/edit', to: 'attribute_fields#edit'
end
end
default_url_options :host => "notebook.ai"
@ -320,9 +323,12 @@ Rails.application.routes.draw do
end
# Content attributes
put '/content/sort', to: 'content#api_sort'
resources :attribute_categories, only: [:create, :update, :destroy]
resources :attribute_fields, only: [:create, :update, :destroy]
resources :attribute_categories, only: [:create, :update, :destroy, :edit] do
get :suggest, on: :collection
end
resources :attribute_fields, only: [:create, :update, :destroy, :edit] do
get :suggest, on: :collection
end
# Image handling
delete '/delete/image/:id', to: 'image_upload#delete', as: :image_deletion
@ -330,6 +336,8 @@ Rails.application.routes.draw do
# Attributes
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
end
# For non-API API endpoints
@ -414,6 +422,14 @@ Rails.application.routes.draw do
end
scope '/fields' do
get '/suggest/:entity_type/:category', to: 'attribute_fields#suggest'
get '/:id/edit', to: 'attributes#field_edit'
end
scope '/categories' do
get '/suggest/:entity_type', to: 'attribute_categories#suggest'
get '/:id/edit', to: 'attributes#category_edit'
end
scope '/content' do
put '/sort', to: 'attributes#sort'
end
scope '/answers' do
get '/suggest/:entity_type/:field_label', to: 'attributes#suggest'

View File

@ -6,6 +6,7 @@
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.41",
"@rails/ujs": "^7.1.3-4",
"@rails/webpacker": "^5.2.1",
"@tailwindcss/forms": "^0.5.0",
"@tailwindcss/line-clamp": "^0.4.2",

View File

@ -1205,6 +1205,11 @@
resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-6.1.7.tgz#b09dc5b2105dd267e8374c47e4490240451dc7f6"
integrity sha512-0e7WQ4LE/+LEfW2zfAw9ppsB6A8RmxbdAUPAF++UT80epY+7emuQDkKXmaK0a9lp6An50RvzezI0cIQjp1A58w==
"@rails/ujs@^7.1.3-4":
version "7.1.3-4"
resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-7.1.3-4.tgz#1dddea99d5c042e8513973ea709b2cb7e840dc2d"
integrity sha512-z0ckI5jrAJfImcObjMT1RBz2IxH6I5q6ZTMFex6AfxSQKZuuL8JxAXvg2CvBuodGCxKvybFVolDyMHXlBLeYAA==
"@rails/webpacker@^5.2.1":
version "5.4.4"
resolved "https://registry.yarnpkg.com/@rails/webpacker/-/webpacker-5.4.4.tgz#971a41b987c096c908ce4088accd57c1a9a7e2f7"