Consolidate attributes editor and fix dashboard image errors

- Merged duplicate attributes editors into single route
- Removed /plan/:content_type/attributes-tailwind route and controller method
- Moved modern Tailwind attributes editor to main /attributes route
- Fixed dashboard ArgumentError: Nil location provided. Can't build URI
- Enhanced HasImageUploads concern with better Active Storage handling
- Added nil-safety guards to header_asset_for method
- Improved caching logic to prevent nil value storage

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andrew Brown 2025-07-07 00:10:33 -07:00
parent a381a4a3f3
commit ada4b03f92
5 changed files with 378 additions and 478 deletions

View File

@ -3,7 +3,7 @@
# TODO: we should probably spin off an Api::ContentController for #api_sort and anything else
# api-wise we need
class ContentController < ApplicationController
layout 'tailwind', only: [:index, :show, :gallery, :references, :deleted, :attributes_tailwind]
layout 'tailwind', only: [:index, :show, :gallery, :references, :deleted, :attributes]
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 }
@ -16,7 +16,7 @@ class ContentController < ApplicationController
before_action :cache_linkable_content_for_each_content_type, only: [:new, :show, :edit, :index]
before_action :set_attributes_content_type, only: [:attributes, :attributes_tailwind, :export_template, :reset_template]
before_action :set_attributes_content_type, only: [:attributes, :export_template, :reset_template]
before_action :set_navbar_color, except: [:api_sort]
before_action :set_navbar_actions, except: [:deleted, :api_sort]
@ -500,18 +500,6 @@ 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)
@dummy_model = @content_type_class.new
# Use the Tailwind layout
render :attributes_tailwind
end
def export_template
service = TemplateExportService.new(current_user, @content_type)

View File

@ -35,17 +35,23 @@ module HasImageUploads
# If we don't have any uploaded images, we look for saved Basil commissions
if result.nil? && respond_to?(:basil_commissions)
result = basil_commissions.where.not(saved_at: nil).includes([:image_attachment]).sample.try(:image)
basil_image = basil_commissions.where.not(saved_at: nil).includes([:image_attachment]).sample.try(:image)
# Handle Active Storage attachments properly
if basil_image.present? && basil_image.respond_to?(:url)
begin
result = basil_image.url
rescue
result = nil
end
end
end
end
# Cache the result
@random_image_including_private_cache[key] = result
# Cache the result (only cache non-nil results to avoid issues)
@random_image_including_private_cache[key] = result if result.present?
# Finally, if we have no image upload, we return the default image for this type
result = result.presence || header_asset_for(self.class.name)
result
# Finally, if we have no valid image URL, return the default image for this type
result.presence || header_asset_for(self.class.name)
end
def first_public_image(format = :medium)
@ -75,7 +81,17 @@ module HasImageUploads
# Then check basil commissions
if respond_to?(:basil_commissions)
pinned_commission = basil_commissions.pinned.where.not(saved_at: nil).includes([:image_attachment]).first
return pinned_commission.try(:image) if pinned_commission.present?
if pinned_commission.present?
basil_image = pinned_commission.try(:image)
# Handle Active Storage attachments properly
if basil_image.present? && basil_image.respond_to?(:url)
begin
return basil_image.url
rescue
return nil
end
end
end
end
nil
@ -88,7 +104,17 @@ module HasImageUploads
if respond_to?(:basil_commissions)
pinned_commission = basil_commissions.pinned.where.not(saved_at: nil).includes([:image_attachment]).first
return pinned_commission.try(:image) if pinned_commission.present?
if pinned_commission.present?
basil_image = pinned_commission.try(:image)
# Handle Active Storage attachments properly
if basil_image.present? && basil_image.respond_to?(:url)
begin
return basil_image.url
rescue
return nil
end
end
end
end
nil
@ -109,9 +135,14 @@ module HasImageUploads
# will not work.
#
# For direct view rendering, we use the relative asset path which works better with image_tag
Rails.env.production? ?
"https://www.notebook.ai" + ActionController::Base.helpers.asset_url("card-headers/#{class_name.downcase.pluralize}.webp") :
ActionController::Base.helpers.asset_path("card-headers/#{class_name.downcase.pluralize}.webp")
asset_filename = "card-headers/#{class_name.downcase.pluralize}.webp"
result = Rails.env.production? ?
"https://www.notebook.ai" + ActionController::Base.helpers.asset_url(asset_filename) :
ActionController::Base.helpers.asset_path(asset_filename)
# Ensure we never return nil - provide a fallback
result.presence || (Rails.env.production? ? "https://www.notebook.ai/assets/#{asset_filename}" : "/assets/#{asset_filename}")
end
end
end

View File

@ -2,117 +2,345 @@
<%= render partial: 'content/components/parallax_header', locals: { content_type: @content_type, content_class: @content_type_class } %>
<% end %>
<!--
<div class="row">
<div class="col s12 m4">
<%= link_to '#' do %>
<div class="hoverable card">
<div class="card-content">
<div class="card-title">
<i class="material-icons left"><%= @content_type_class.icon %></i>
Customize icon
</div>
You can customize this page.
</div>
</div>
<% end %>
<%= javascript_pack_tag 'template_editor' %>
<div class="attributes-editor max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
data-content-type="<%= @content_type %>"
style="--content-type-color: <%= @content_type_class.hex_color %>;"
x-data="initTemplateEditor()">
<!-- Mobile Panel Switch Tabs (visible only on small screens) -->
<div class="md:hidden bg-white border-b border-gray-200 sticky top-0 z-10">
<div class="flex -mb-px">
<button @click="activePanel = 'template'"
:style="activePanel === 'template' ? 'border-color: var(--content-type-color); color: var(--content-type-color)' : ''"
:class="activePanel === 'template' ? '' : 'border-transparent text-gray-500'"
class="flex-1 py-4 px-1 text-center border-b-2 font-medium text-sm transition-colors">
Template Editor
</button>
<button @click="activePanel = 'config'; configuring = true;"
:style="activePanel === 'config' ? 'border-color: var(--content-type-color); color: var(--content-type-color)' : ''"
:class="activePanel === 'config' ? '' : 'border-transparent text-gray-500'"
class="flex-1 py-4 px-1 text-center border-b-2 font-medium text-sm transition-colors">
Configuration
</button>
</div>
</div>
<div class="col s12 m4">
<%= link_to '#' do %>
<div class="hoverable card white-text" style="background-color: <%= @content_type_class.hex_color_for(current_user) %>">
<div class="card-content">
<div class="card-title">Customize color</div>
You can recolor this page.
<!-- Main Content Area - Two Panel Layout -->
<div class="flex flex-col md:flex-row gap-6 mt-4">
<!-- Template Panel (always visible on desktop, conditionally on mobile) -->
<div :class="{ 'hidden md:block': activePanel === 'config' }"
class="flex-1 min-w-0">
<!-- Streamlined Header -->
<div class="template-header relative overflow-hidden rounded-lg shadow-md mb-6 bg-white">
<!-- Background with Frosted Glass Effect -->
<div class="absolute inset-0">
<%= image_tag "card-headers/#{@content_type.downcase.pluralize}.jpg",
class: "hero-bg-image w-full h-full object-cover",
alt: "#{@content_type.titleize} background" %>
<!-- Frosted glass overlay for better text readability -->
<div class="absolute inset-0 bg-black/60 backdrop-blur-lg"></div>
<div class="absolute inset-0" style="background: linear-gradient(135deg, <%= @content_type_class.hex_color %>aa 0%, <%= @content_type_class.hex_color %>66 50%, rgba(0,0,0,0.3) 100%);"></div>
</div>
</div>
<% end %>
</div>
<div class="col s12 m4">
<%= link_to '#' do %>
<div class="hoverable card">
<div class="card-content">
<div class="card-title">Customize header image</div>
You can change the header image.
</div>
</div>
<% end %>
</div>
</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 } %>
<% @attribute_categories.each do |attribute_category| %>
<li data-position="<%= attribute_category.position %>" data-content-id="<%= attribute_category.id %>" class="js-category-container">
<div class="collapsible-header">
<span class="sortable-handle"><i class="material-icons grey-text">menu</i></span>
<i class="material-icons"><%= attribute_category.icon %></i>
<div class="js-category-label">
<%= attribute_category.label %>
</div>
<% if attribute_category.hidden? %>
<span class="grey-text">&mdash;</span>
<i class="material-icons grey-text tooltipped" data-tooltip="You've hidden this category.">visibility_off</i>
<% end %>
</div>
<div class="collapsible-body">
<div class="row">
<% if attribute_category.hidden? %>
<div class="col s12 m12 l12">
<%= render partial: 'content/attributes/category_visibility_controls', locals: { category: attribute_category } %>
<!-- Content -->
<div class="relative z-10 px-6 py-5">
<div class="flex items-start justify-between">
<!-- Icon and Title -->
<div class="flex items-center">
<div class="w-12 h-12 rounded-lg flex items-center justify-center mr-4 content-type-icon-bg">
<i class="material-icons text-white text-xl"><%= @content_type_class.icon %></i>
</div>
<% else %>
<div class="col s12 m12 l8">
<%= render partial: 'content/attributes/category_fields_customization', locals: { category: attribute_category } %>
<div>
<h1 class="text-2xl font-semibold text-white leading-tight mb-1">
<%= @content_type.titleize %> Template Editor
</h1>
<p class="text-white text-sm leading-relaxed max-w-2xl">
Edit the template used for your <%= @content_type.titleize.downcase %> pages. Adding or removing a field here will modify all of your already-created <%= @content_type.titleize.downcase %> pages also.
</p>
</div>
<div class="col s12 m12 l4">
<%= render partial: 'content/attributes/category_ui_customization', locals: { category: attribute_category } %>
<%= render partial: 'content/attributes/category_visibility_controls', locals: { category: attribute_category } %>
</div>
<% end %>
</div>
</div>
</li>
<% end %>
<li>
<div class="collapsible-header">
<i class="material-icons <%= @content_type_class.text_color %>">add</i>
Add another category
</div>
<div class="collapsible-body">
<%= form_for(current_user.attribute_categories.build, method: :post) do |f| %>
<%= f.hidden_field :entity_type, value: @content_type %>
<div class="row">
<div class="input-field col s12">
<%= f.text_area :label, class: 'materialize-textarea js-category-input' %>
<%= f.label :label, 'Category label' %>
</div>
<div class="input-field col s12">
<%= f.submit 'Add new category', class: "btn #{@content_type_class.color}" %>
</div>
</div>
</div>
</div>
<div class="row">
<div class="grey-text text-darken-1">
<strong>New:</strong> Notebook.ai can now suggest additional categories for your pages.
</div>
<p class="suggest-categories-container">
</p>
<%= f.button "Show suggestions", class: 'small btn white black-text js-show-category-suggestions' %>
</div>
<!-- Categories Container -->
<div id="categories-container" class="space-y-4" data-sortable-class="AttributeCategory">
<% @attribute_categories.each do |category| %>
<%= render partial: 'content/attributes/tailwind/category_card', locals: {
category: category,
content_type_class: @content_type_class,
content_type: @content_type
} %>
<% end %>
<!-- Add Category Card -->
<div class="bg-white rounded-lg shadow-sm border border-dashed border-gray-300 hover:border-gray-400 transition-colors p-4">
<div class="flex justify-center items-center p-6 cursor-pointer"
data-action="click->attributes-editor#showAddCategoryForm">
<i class="material-icons text-gray-400 mr-2">add_circle_outline</i>
<span class="text-gray-500 font-medium">Add a New Category</span>
</div>
<!-- Category Form (Hidden by Default) -->
<div id="add-category-form" class="hidden p-4 border-t border-gray-200 mt-4">
<%= form_for(current_user.attribute_categories.build, method: :post, html: { class: "space-y-4", 'data-type': 'json', 'data-remote': 'true', '@submit.prevent': 'submitCategoryForm($event)' }, remote: true) do |f| %>
<%= f.hidden_field :entity_type, value: @content_type %>
<div>
<label for="category_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: var(--content-type-color); --tw-border-opacity: 1; border-color: var(--content-type-color);" %>
</div>
<div class="flex justify-end space-x-3">
<button type="button"
@click="document.getElementById('add-category-form').classList.add('hidden')"
class="px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none">
Cancel
</button>
<%= f.submit "Add Category", 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: var(--content-type-color); --tw-ring-color: var(--content-type-color);" %>
</div>
<% end %>
<div class="mt-6 pt-4 border-t border-gray-200">
<div class="text-sm font-medium text-gray-700 mb-2">Category Suggestions</div>
<p class="text-sm text-gray-500 mb-2">
Notebook.ai can suggest additional categories for your pages.
</p>
<div class="suggest-categories-container flex flex-wrap gap-2 mb-3"></div>
<button type="button" class="js-show-category-suggestions px-3 py-1.5 border border-gray-300 rounded-md text-xs font-medium text-gray-700 hover:bg-gray-50 focus:outline-none">
Show Suggestions
</button>
</div>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
<!-- Configuration Panel (always visible on desktop, conditionally on mobile) -->
<div :class="{ 'hidden md:block': activePanel === 'template' && !configuring }"
class="w-full md:w-96 lg:w-96"
id="config-panel-container">
<!-- Sticky Container for Desktop/Tablet -->
<div class="config-panel-sticky">
<!-- No Selection State -->
<div x-show="!selectedCategory && !selectedField && !configuring"
class="bg-white rounded-lg shadow-md p-6 text-center config-panel-content">
<div class="mx-auto w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<i class="material-icons text-gray-400 text-2xl">tune</i>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Configure Your Template</h3>
<p class="text-sm text-gray-500 mb-4">
Select a category or field to customize it, or add a new one to get started.
</p>
<p class="text-xs text-gray-400 mb-1">Tip: You can drag and drop to reorder categories and fields.</p>
<button @click="configuring = true" class="mt-2 px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none">
<span>General Settings</span>
</button>
</div>
<!-- Field Configuration -->
<div x-show="selectedField"
class="bg-white rounded-lg shadow-md overflow-hidden config-panel-content">
<!-- Content will be loaded dynamically -->
<div id="field-config-container" class="p-4">
<div class="flex justify-center items-center p-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2" style="border-color: var(--content-type-color);"></div>
<span class="ml-2 text-gray-600">Loading field settings...</span>
</div>
</div>
</div>
<!-- Category Configuration -->
<div x-show="selectedCategory"
class="bg-white rounded-lg shadow-md overflow-hidden config-panel-content">
<!-- Content will be loaded dynamically -->
<div id="category-config-container" class="p-4">
<div class="flex justify-center items-center p-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2" style="border-color: var(--content-type-color);"></div>
<span class="ml-2 text-gray-600">Loading category settings...</span>
</div>
</div>
</div>
<!-- General Configuration -->
<div x-show="configuring && !selectedCategory && !selectedField"
class="bg-white rounded-lg shadow-md overflow-hidden config-panel-content">
<%= render partial: 'content/attributes/tailwind/general_settings', locals: {
content_type: @content_type,
content_type_class: @content_type_class
} %>
</div>
</div> <!-- End sticky container -->
</div> <!-- End config panel container -->
</div> <!-- End main content area -->
</div>
<style>
/* Streamlined Header Styles */
.template-header {
position: relative;
transition: all 0.2s ease;
}
.template-header:hover {
shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* Subtle background image effect */
.hero-bg-image {
transition: opacity 0.3s ease;
}
.template-header:hover .hero-bg-image {
opacity: 0.8;
}
/* Content type icon styling */
.content-type-icon-bg {
background: linear-gradient(135deg, var(--content-type-color) 0%, color-mix(in srgb, var(--content-type-color) 80%, white) 100%);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.content-type-icon-bg:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
/* Interactive hint styling */
.interactive-hint {
transition: all 0.2s ease;
cursor: default;
}
.interactive-hint:hover {
color: rgba(255, 255, 255, 1);
}
.interactive-hint:hover i {
color: rgba(255, 255, 255, 0.9);
transform: scale(1.05);
}
/* jQuery UI Sortable placeholder styles */
.category-placeholder {
visibility: visible !important;
background: rgba(59, 130, 246, 0.1);
border: 2px dashed rgba(59, 130, 246, 0.3);
border-radius: 8px;
height: 120px;
margin: 1rem 0;
}
.field-placeholder {
visibility: visible !important;
background: rgba(59, 130, 246, 0.1);
border: 2px dashed rgba(59, 130, 246, 0.3);
border-radius: 6px;
height: 60px;
margin: 0.5rem 0;
}
/* Improve drag handle appearance */
.category-drag-handle,
.field-drag-handle {
cursor: grab;
}
.category-drag-handle:active,
.field-drag-handle:active {
cursor: grabbing;
}
/* Category header styling */
.category-header {
transition: all 0.2s ease;
}
.category-header:hover {
filter: brightness(0.95);
}
.category-header:active {
filter: brightness(0.9);
}
/* Sortable helper styles */
.ui-sortable-helper {
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15) !important;
transform: rotate(2deg);
}
/* Notification animations */
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-notification-in {
animation: slideInRight 0.3s ease-out;
}
/* Loading spinner animation */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* Smart sticky configuration panel */
.config-panel-sticky {
position: sticky;
top: 2rem;
transition: all 0.2s ease-out;
max-height: calc(100vh - 4rem);
overflow-y: auto;
}
/* On mobile, don't make it sticky to avoid layout issues */
@media (max-width: 768px) {
.config-panel-sticky {
position: static;
max-height: none;
overflow-y: visible;
}
}
/* Ensure the configuration panels have proper scrolling */
.config-panel-content {
max-height: calc(100vh - 6rem);
overflow-y: auto;
}
@media (max-width: 768px) {
.config-panel-content {
max-height: none;
overflow-y: visible;
}
}
</style>
<!-- Template editor JavaScript functionality is loaded via the template_editor pack -->

View File

@ -1,346 +0,0 @@
<%= content_for :full_width_page_header do %>
<%= render partial: 'content/components/parallax_header', locals: { content_type: @content_type, content_class: @content_type_class } %>
<% end %>
<%= javascript_pack_tag 'template_editor' %>
<div class="attributes-editor max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
data-content-type="<%= @content_type %>"
style="--content-type-color: <%= @content_type_class.hex_color %>;"
x-data="initTemplateEditor()">
<!-- Mobile Panel Switch Tabs (visible only on small screens) -->
<div class="md:hidden bg-white border-b border-gray-200 sticky top-0 z-10">
<div class="flex -mb-px">
<button @click="activePanel = 'template'"
:style="activePanel === 'template' ? 'border-color: var(--content-type-color); color: var(--content-type-color)' : ''"
:class="activePanel === 'template' ? '' : 'border-transparent text-gray-500'"
class="flex-1 py-4 px-1 text-center border-b-2 font-medium text-sm transition-colors">
Template Editor
</button>
<button @click="activePanel = 'config'; configuring = true;"
:style="activePanel === 'config' ? 'border-color: var(--content-type-color); color: var(--content-type-color)' : ''"
:class="activePanel === 'config' ? '' : 'border-transparent text-gray-500'"
class="flex-1 py-4 px-1 text-center border-b-2 font-medium text-sm transition-colors">
Configuration
</button>
</div>
</div>
<!-- Main Content Area - Two Panel Layout -->
<div class="flex flex-col md:flex-row gap-6 mt-4">
<!-- Template Panel (always visible on desktop, conditionally on mobile) -->
<div :class="{ 'hidden md:block': activePanel === 'config' }"
class="flex-1 min-w-0">
<!-- Streamlined Header -->
<div class="template-header relative overflow-hidden rounded-lg shadow-md mb-6 bg-white">
<!-- Background with Frosted Glass Effect -->
<div class="absolute inset-0">
<%= image_tag "card-headers/#{@content_type.downcase.pluralize}.jpg",
class: "hero-bg-image w-full h-full object-cover",
alt: "#{@content_type.titleize} background" %>
<!-- Frosted glass overlay for better text readability -->
<div class="absolute inset-0 bg-black/60 backdrop-blur-lg"></div>
<div class="absolute inset-0" style="background: linear-gradient(135deg, <%= @content_type_class.hex_color %>aa 0%, <%= @content_type_class.hex_color %>66 50%, rgba(0,0,0,0.3) 100%);"></div>
</div>
<!-- Content -->
<div class="relative z-10 px-6 py-5">
<div class="flex items-start justify-between">
<!-- Icon and Title -->
<div class="flex items-center">
<div class="w-12 h-12 rounded-lg flex items-center justify-center mr-4 content-type-icon-bg">
<i class="material-icons text-white text-xl"><%= @content_type_class.icon %></i>
</div>
<div>
<h1 class="text-2xl font-semibold text-white leading-tight mb-1">
<%= @content_type.titleize %> Template Editor
</h1>
<p class="text-white text-sm leading-relaxed max-w-2xl">
Edit the template used for your <%= @content_type.titleize.downcase %> pages. Adding or removing a field here will modify all of your already-created <%= @content_type.titleize.downcase %> pages also.
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Categories Container -->
<div id="categories-container" class="space-y-4" data-sortable-class="AttributeCategory">
<% @attribute_categories.each do |category| %>
<%= render partial: 'content/attributes/tailwind/category_card', locals: {
category: category,
content_type_class: @content_type_class,
content_type: @content_type
} %>
<% end %>
<!-- Add Category Card -->
<div class="bg-white rounded-lg shadow-sm border border-dashed border-gray-300 hover:border-gray-400 transition-colors p-4">
<div class="flex justify-center items-center p-6 cursor-pointer"
data-action="click->attributes-editor#showAddCategoryForm">
<i class="material-icons text-gray-400 mr-2">add_circle_outline</i>
<span class="text-gray-500 font-medium">Add a New Category</span>
</div>
<!-- Category Form (Hidden by Default) -->
<div id="add-category-form" class="hidden p-4 border-t border-gray-200 mt-4">
<%= form_for(current_user.attribute_categories.build, method: :post, html: { class: "space-y-4", 'data-type': 'json', 'data-remote': 'true', '@submit.prevent': 'submitCategoryForm($event)' }, remote: true) do |f| %>
<%= f.hidden_field :entity_type, value: @content_type %>
<div>
<label for="category_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: var(--content-type-color); --tw-border-opacity: 1; border-color: var(--content-type-color);" %>
</div>
<div class="flex justify-end space-x-3">
<button type="button"
@click="document.getElementById('add-category-form').classList.add('hidden')"
class="px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none">
Cancel
</button>
<%= f.submit "Add Category", 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: var(--content-type-color); --tw-ring-color: var(--content-type-color);" %>
</div>
<% end %>
<div class="mt-6 pt-4 border-t border-gray-200">
<div class="text-sm font-medium text-gray-700 mb-2">Category Suggestions</div>
<p class="text-sm text-gray-500 mb-2">
Notebook.ai can suggest additional categories for your pages.
</p>
<div class="suggest-categories-container flex flex-wrap gap-2 mb-3"></div>
<button type="button" class="js-show-category-suggestions px-3 py-1.5 border border-gray-300 rounded-md text-xs font-medium text-gray-700 hover:bg-gray-50 focus:outline-none">
Show Suggestions
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Configuration Panel (always visible on desktop, conditionally on mobile) -->
<div :class="{ 'hidden md:block': activePanel === 'template' && !configuring }"
class="w-full md:w-96 lg:w-96"
id="config-panel-container">
<!-- Sticky Container for Desktop/Tablet -->
<div class="config-panel-sticky">
<!-- No Selection State -->
<div x-show="!selectedCategory && !selectedField && !configuring"
class="bg-white rounded-lg shadow-md p-6 text-center config-panel-content">
<div class="mx-auto w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<i class="material-icons text-gray-400 text-2xl">tune</i>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Configure Your Template</h3>
<p class="text-sm text-gray-500 mb-4">
Select a category or field to customize it, or add a new one to get started.
</p>
<p class="text-xs text-gray-400 mb-1">Tip: You can drag and drop to reorder categories and fields.</p>
<button @click="configuring = true" class="mt-2 px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none">
<span>General Settings</span>
</button>
</div>
<!-- Field Configuration -->
<div x-show="selectedField"
class="bg-white rounded-lg shadow-md overflow-hidden config-panel-content">
<!-- Content will be loaded dynamically -->
<div id="field-config-container" class="p-4">
<div class="flex justify-center items-center p-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2" style="border-color: var(--content-type-color);"></div>
<span class="ml-2 text-gray-600">Loading field settings...</span>
</div>
</div>
</div>
<!-- Category Configuration -->
<div x-show="selectedCategory"
class="bg-white rounded-lg shadow-md overflow-hidden config-panel-content">
<!-- Content will be loaded dynamically -->
<div id="category-config-container" class="p-4">
<div class="flex justify-center items-center p-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2" style="border-color: var(--content-type-color);"></div>
<span class="ml-2 text-gray-600">Loading category settings...</span>
</div>
</div>
</div>
<!-- General Configuration -->
<div x-show="configuring && !selectedCategory && !selectedField"
class="bg-white rounded-lg shadow-md overflow-hidden config-panel-content">
<%= render partial: 'content/attributes/tailwind/general_settings', locals: {
content_type: @content_type,
content_type_class: @content_type_class
} %>
</div>
</div> <!-- End sticky container -->
</div> <!-- End config panel container -->
</div> <!-- End main content area -->
</div>
<style>
/* Streamlined Header Styles */
.template-header {
position: relative;
transition: all 0.2s ease;
}
.template-header:hover {
shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* Subtle background image effect */
.hero-bg-image {
transition: opacity 0.3s ease;
}
.template-header:hover .hero-bg-image {
opacity: 0.8;
}
/* Content type icon styling */
.content-type-icon-bg {
background: linear-gradient(135deg, var(--content-type-color) 0%, color-mix(in srgb, var(--content-type-color) 80%, white) 100%);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.content-type-icon-bg:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
/* Interactive hint styling */
.interactive-hint {
transition: all 0.2s ease;
cursor: default;
}
.interactive-hint:hover {
color: rgba(255, 255, 255, 1);
}
.interactive-hint:hover i {
color: rgba(255, 255, 255, 0.9);
transform: scale(1.05);
}
/* jQuery UI Sortable placeholder styles */
.category-placeholder {
visibility: visible !important;
background: rgba(59, 130, 246, 0.1);
border: 2px dashed rgba(59, 130, 246, 0.3);
border-radius: 8px;
height: 120px;
margin: 1rem 0;
}
.field-placeholder {
visibility: visible !important;
background: rgba(59, 130, 246, 0.1);
border: 2px dashed rgba(59, 130, 246, 0.3);
border-radius: 6px;
height: 60px;
margin: 0.5rem 0;
}
/* Improve drag handle appearance */
.category-drag-handle,
.field-drag-handle {
cursor: grab;
}
.category-drag-handle:active,
.field-drag-handle:active {
cursor: grabbing;
}
/* Category header styling */
.category-header {
transition: all 0.2s ease;
}
.category-header:hover {
filter: brightness(0.95);
}
.category-header:active {
filter: brightness(0.9);
}
/* Sortable helper styles */
.ui-sortable-helper {
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15) !important;
transform: rotate(2deg);
}
/* Notification animations */
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-notification-in {
animation: slideInRight 0.3s ease-out;
}
/* Loading spinner animation */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* Smart sticky configuration panel */
.config-panel-sticky {
position: sticky;
top: 2rem;
transition: all 0.2s ease-out;
max-height: calc(100vh - 4rem);
overflow-y: auto;
}
/* On mobile, don't make it sticky to avoid layout issues */
@media (max-width: 768px) {
.config-panel-sticky {
position: static;
max-height: none;
overflow-y: visible;
}
}
/* Ensure the configuration panels have proper scrolling */
.config-panel-content {
max-height: calc(100vh - 6rem);
overflow-y: auto;
}
@media (max-width: 768px) {
.config-panel-content {
max-height: none;
overflow-y: visible;
}
}
</style>
<!-- Template editor JavaScript functionality is loaded via the template_editor pack -->

View File

@ -338,7 +338,6 @@ 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
delete ':content_type/template/reset', to: 'content#reset_template', as: :reset_template
end