mirror of
https://github.com/indentlabs/notebook.git
synced 2025-10-26 11:19:22 +00:00
new template editor
This commit is contained in:
parent
7af35d1946
commit
df56671ed3
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
37
app/controllers/api/v1/content_controller.rb
Normal file
37
app/controllers/api/v1/content_controller.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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");
|
||||
|
||||
250
app/javascript/packs/template_editor.js
Normal file
250
app/javascript/packs/template_editor.js
Normal 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';
|
||||
}
|
||||
}
|
||||
});
|
||||
233
app/services/template_export_service.rb
Normal file
233
app/services/template_export_service.rb
Normal 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
|
||||
@ -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 } %>
|
||||
|
||||
191
app/views/content/attributes/tailwind/_category_card.html.erb
Normal file
191
app/views/content/attributes/tailwind/_category_card.html.erb
Normal 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>
|
||||
177
app/views/content/attributes/tailwind/_category_config.html.erb
Normal file
177
app/views/content/attributes/tailwind/_category_config.html.erb
Normal 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>
|
||||
253
app/views/content/attributes/tailwind/_field_config.html.erb
Normal file
253
app/views/content/attributes/tailwind/_field_config.html.erb
Normal 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... It spans multiple lines Perfect for longer descriptions"
|
||||
disabled
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white text-gray-500 cursor-not-allowed resize-none"></textarea>
|
||||
|
||||
<!-- Large textarea preview -->
|
||||
<textarea x-show="selectedSize === 'large'"
|
||||
placeholder="This is how the large textarea will look... It has more vertical space Ideal for rich text content Longer descriptions Multiple paragraphs And more detailed information"
|
||||
disabled
|
||||
rows="6"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white text-gray-500 cursor-not-allowed resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% elsif field.field_type == 'link' %>
|
||||
<%= form_for(field, method: :put, html: {class: 'space-y-4', 'data-type': 'json', id: 'linkable-types-form'}, remote: true) do |f| %>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700">Linkable Types</label>
|
||||
<div class="flex space-x-2">
|
||||
<button type="button"
|
||||
onclick="selectAllLinkableTypes()"
|
||||
class="px-2 py-1 text-xs font-medium text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded transition-colors">
|
||||
Select All
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="selectNoneLinkableTypes()"
|
||||
class="px-2 py-1 text-xs font-medium text-gray-600 hover:text-gray-700 hover:bg-gray-50 rounded transition-colors">
|
||||
Select None
|
||||
</button>
|
||||
</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>
|
||||
99
app/views/content/attributes/tailwind/_field_item.html.erb
Normal file
99
app/views/content/attributes/tailwind/_field_item.html.erb
Normal 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>
|
||||
148
app/views/content/attributes/tailwind/_general_settings.html.erb
Normal file
148
app/views/content/attributes/tailwind/_general_settings.html.erb
Normal 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>
|
||||
1553
app/views/content/attributes_tailwind.html.erb
Normal file
1553
app/views/content/attributes_tailwind.html.erb
Normal file
File diff suppressed because it is too large
Load Diff
@ -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'
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user