mirror of
https://github.com/indentlabs/notebook.git
synced 2025-10-26 11:19:22 +00:00
galleries cleanup & image reordering
This commit is contained in:
parent
0f57473bd9
commit
307bb4137d
66
app/controllers/api/v1/gallery_images_controller.rb
Normal file
66
app/controllers/api/v1/gallery_images_controller.rb
Normal file
@ -0,0 +1,66 @@
|
||||
class Api::V1::GalleryImagesController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
skip_before_action :verify_authenticity_token, only: [:sort]
|
||||
|
||||
# POST /api/v1/gallery_images/sort
|
||||
# Handles sorting of gallery images (both ImageUploads and BasilCommissions)
|
||||
# Expected params:
|
||||
# - images: Array of hashes with:
|
||||
# - id: Image ID
|
||||
# - type: 'image_upload' or 'basil_commission'
|
||||
# - position: New position
|
||||
# - content_type: Content type (e.g., 'Character')
|
||||
# - content_id: Content ID
|
||||
def sort
|
||||
# Validate ownership/contribution permissions for the content
|
||||
content_type = params[:content_type]
|
||||
content_id = params[:content_id]
|
||||
content = content_type.constantize.find_by(id: content_id)
|
||||
|
||||
# Check permissions - must own or contribute to this content
|
||||
unless content &&
|
||||
(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: :unauthorized
|
||||
end
|
||||
|
||||
# Process each image in the array
|
||||
success = true
|
||||
|
||||
# Use a transaction to ensure all positions are updated or none are
|
||||
ActiveRecord::Base.transaction do
|
||||
params[:images].each do |image_data|
|
||||
if image_data[:type] == 'image_upload'
|
||||
# Update ImageUpload position
|
||||
image = ImageUpload.find_by(id: image_data[:id])
|
||||
if image && image.content_type == content_type && image.content_id.to_s == content_id.to_s
|
||||
image.insert_at(image_data[:position].to_i)
|
||||
else
|
||||
success = false
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
elsif image_data[:type] == 'basil_commission'
|
||||
# Update BasilCommission position
|
||||
image = BasilCommission.find_by(id: image_data[:id])
|
||||
if image && image.entity_type == content_type && image.entity_id.to_s == content_id.to_s
|
||||
image.insert_at(image_data[:position].to_i)
|
||||
else
|
||||
success = false
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
else
|
||||
success = false
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if success
|
||||
render json: { success: true }
|
||||
else
|
||||
render json: { error: 'Failed to update image positions' }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -425,16 +425,22 @@ class ContentController < ApplicationController
|
||||
# Serialize content for overview section
|
||||
@serialized_content = ContentSerializer.new(@content)
|
||||
|
||||
# Get all images for this content
|
||||
@images = ImageUpload.where(content_type: @content.class.name, content_id: @content.id)
|
||||
# Get all images for this content with proper ordering
|
||||
@images = ImageUpload.where(content_type: @content.class.name, content_id: @content.id).ordered
|
||||
|
||||
# Get additional context information
|
||||
@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) : []
|
||||
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
|
||||
@basil_images = BasilCommission.where(entity: @content).where.not(saved_at: nil)
|
||||
# Include basil images too with proper ordering
|
||||
@basil_images = BasilCommission.where(entity: @content).where.not(saved_at: nil).ordered
|
||||
|
||||
render 'content/gallery'
|
||||
else
|
||||
|
||||
@ -4,8 +4,9 @@ class BasilCommission < ApplicationRecord
|
||||
belongs_to :user, optional: true
|
||||
belongs_to :entity, polymorphic: true, optional: true
|
||||
|
||||
# Add scope for pinned images
|
||||
# Add scopes for image ordering
|
||||
scope :pinned, -> { where(pinned: true) }
|
||||
scope :ordered, -> { order(:position) }
|
||||
|
||||
has_one_attached :image,
|
||||
service: :amazon_basil,
|
||||
@ -48,6 +49,9 @@ class BasilCommission < ApplicationRecord
|
||||
image.attached?
|
||||
end
|
||||
|
||||
# Use acts_as_list for ordering images
|
||||
acts_as_list scope: [:entity_type, :entity_id]
|
||||
|
||||
# Add callback to ensure only one pinned image per entity
|
||||
before_save :ensure_single_pinned_image, if: -> { pinned_changed? && pinned? }
|
||||
|
||||
|
||||
@ -89,11 +89,24 @@ module HasImageUploads
|
||||
nil
|
||||
end
|
||||
|
||||
def pinned_or_random_image_including_private(format: :medium)
|
||||
# First check for pinned images
|
||||
pinned = pinned_image_upload(format)
|
||||
return pinned if pinned.present?
|
||||
|
||||
# If no pinned image, fall back to random selection
|
||||
random_image_including_private(format: format)
|
||||
end
|
||||
|
||||
def header_asset_for(class_name)
|
||||
# Since we use this as a fallback image on SEO content (for example, Twitter cards for shared notebook pages),
|
||||
# we need to include the full protocol + domain + path to ensure they will display the image. A relative path
|
||||
# will not work.
|
||||
"https://www.notebook.ai" + ActionController::Base.helpers.asset_url("card-headers/#{class_name.downcase.pluralize}.webp")
|
||||
#
|
||||
# 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")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@ -2,8 +2,9 @@ class ImageUpload < ApplicationRecord
|
||||
belongs_to :user, optional: true
|
||||
belongs_to :content, polymorphic: true
|
||||
|
||||
# Add scope for pinned images
|
||||
# Add scopes for image ordering
|
||||
scope :pinned, -> { where(pinned: true) }
|
||||
scope :ordered, -> { order(:position) }
|
||||
|
||||
# This is the old way we uploaded files -- now we're transitioning to ActiveStorage's has_one_attached
|
||||
has_attached_file :src,
|
||||
@ -35,8 +36,8 @@ class ImageUpload < ApplicationRecord
|
||||
alias_attribute 'character_id', :content_id
|
||||
#alias_attribute ...
|
||||
|
||||
# Add missing Paperclip attributes for development environment
|
||||
attr_accessor :src_file_name, :src_content_type, :src_file_size, :src_updated_at
|
||||
# Use acts_as_list for ordering images
|
||||
acts_as_list scope: [:content_type, :content_id]
|
||||
|
||||
# Add callback to ensure only one pinned image per content
|
||||
before_save :ensure_single_pinned_image, if: -> { pinned_changed? && pinned? }
|
||||
|
||||
@ -10,9 +10,19 @@ class ContentPage < ApplicationRecord
|
||||
self.authorizer_name = 'ContentPageAuthorizer'
|
||||
|
||||
def random_image_including_private(format: :small)
|
||||
ImageUpload.where(content_type: self.page_type, content_id: self.id).sample.try(:src, format) \
|
||||
|| BasilCommission.where(entity_type: self.page_type, entity_id: self.id).where.not(saved_at: nil).includes([:image_attachment]).sample.try(:image) \
|
||||
|| ActionController::Base.helpers.asset_path("card-headers/#{self.page_type.downcase.pluralize}.webp")
|
||||
pinned_image = ImageUpload.where(content_type: self.page_type, content_id: self.id, pinned: true).first
|
||||
return pinned_image.src(format) if pinned_image
|
||||
|
||||
pinned_commission = BasilCommission.where(entity_type: self.page_type, entity_id: self.id, pinned: true).where.not(saved_at: nil).includes([:image_attachment]).first
|
||||
return pinned_commission.image if pinned_commission
|
||||
|
||||
random_image = ImageUpload.where(content_type: self.page_type, content_id: self.id).sample
|
||||
return random_image.src(format) if random_image
|
||||
|
||||
random_commission = BasilCommission.where(entity_type: self.page_type, entity_id: self.id).where.not(saved_at: nil).includes([:image_attachment]).sample
|
||||
return random_commission.image if random_commission
|
||||
|
||||
ActionController::Base.helpers.asset_path("card-headers/#{self.page_type.downcase.pluralize}.webp")
|
||||
end
|
||||
|
||||
def icon
|
||||
|
||||
@ -63,7 +63,11 @@
|
||||
%>
|
||||
<% @content.public_image_uploads.each do |image_source| %>
|
||||
<li>
|
||||
<%= image_tag image_source.is_a?(String) ? image_source : image_source.src(:large) %>
|
||||
<% if image_source.is_a?(String) %>
|
||||
<img src="<%= image_source %>">
|
||||
<% else %>
|
||||
<%= image_tag image_source.src(:large) %>
|
||||
<% end %>
|
||||
<div class="caption bordered-text center">
|
||||
<h3>
|
||||
<% if @content.persisted? %>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
<%
|
||||
raw_model = content.is_a?(ContentSerializer) ? content.raw_model : content
|
||||
|
||||
# Get both image types
|
||||
regular_images = raw_model.image_uploads.to_a
|
||||
basil_images = (@basil_images || [])
|
||||
# Get both image types with ordering
|
||||
regular_images = raw_model.image_uploads.ordered.to_a
|
||||
basil_images = (@basil_images || []).sort_by(&:position)
|
||||
|
||||
# Keep track of regular and AI images separately for display
|
||||
images = regular_images
|
||||
@ -13,6 +13,32 @@
|
||||
|
||||
# Find the pinned image (if any) - there should only be one pinned image
|
||||
pinned_image = regular_images.find(&:pinned?) || basil_images.find(&:pinned?)
|
||||
|
||||
# Create a combined list of all images for sortable functionality
|
||||
# We'll maintain type information for each
|
||||
combined_images = []
|
||||
regular_images.each do |image|
|
||||
combined_images << {
|
||||
id: image.id,
|
||||
type: 'image_upload',
|
||||
data: image,
|
||||
pinned: image.pinned?
|
||||
}
|
||||
end
|
||||
|
||||
basil_images.each do |commission|
|
||||
combined_images << {
|
||||
id: commission.id,
|
||||
type: 'basil_commission',
|
||||
data: commission,
|
||||
pinned: commission.pinned?
|
||||
}
|
||||
end
|
||||
|
||||
# If there's a pinned image, move it to the front
|
||||
if pinned_image
|
||||
combined_images.sort_by! { |img| img[:pinned] ? 0 : 1 }
|
||||
end
|
||||
%>
|
||||
|
||||
<div class="row">
|
||||
@ -23,204 +49,118 @@
|
||||
<h5 class="gallery-section-title">
|
||||
<i class="material-icons left">photo_library</i>
|
||||
Current Images (<%= total_images %>)
|
||||
<span class="right grey-text text-lighten-1 reordering-instructions" style="font-size: 1rem; display: none;">
|
||||
<i class="material-icons left">touch_app</i> Drag images to reorder
|
||||
</span>
|
||||
</h5>
|
||||
|
||||
<div class="gallery-grid">
|
||||
<div class="row">
|
||||
<!-- First show the pinned image (if any) -->
|
||||
<% if pinned_image && regular_images.include?(pinned_image) %>
|
||||
<div class="col s12 m6 l6">
|
||||
<div class="image-card">
|
||||
<div class="image-preview">
|
||||
<%= link_to pinned_image.src(:original), target: '_blank' do %>
|
||||
<%= image_tag pinned_image.src(:medium), class: "responsive-img" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="image-actions">
|
||||
<div class="image-meta">
|
||||
<span class="file-size"><%= Filesize.from("#{pinned_image.src_file_size}B").to_f('KB').round(2) %> KB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pin button with tooltip (positioned absolutely) -->
|
||||
<%= link_to toggle_image_pin_path(image_type: 'image_upload', image_id: pinned_image.id),
|
||||
class: "pin-button-overlay js-toggle-pin amber-text tooltipped",
|
||||
remote: true,
|
||||
method: :post,
|
||||
data: {
|
||||
type: 'json',
|
||||
position: 'top',
|
||||
tooltip: 'Pin this image to always use it in previews of this page'
|
||||
} do %>
|
||||
<i class="material-icons">push_pin</i>
|
||||
<% end %>
|
||||
|
||||
<!-- Delete button with tooltip (positioned absolutely) -->
|
||||
<%= link_to image_deletion_path(pinned_image.id),
|
||||
class: 'delete-button-overlay js-remove-image red-text tooltipped',
|
||||
method: 'delete',
|
||||
remote: true,
|
||||
data: {
|
||||
confirm: "Are you sure? This can't be undone.",
|
||||
position: 'top',
|
||||
tooltip: 'Delete this image'
|
||||
} do %>
|
||||
<i class="material-icons">delete</i>
|
||||
<% end %>
|
||||
|
||||
<div class="pinned-badge-overlay">
|
||||
<i class="material-icons amber-text">push_pin</i> Pinned
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% elsif pinned_image && basil_images.include?(pinned_image) %>
|
||||
<div class="col s12 m6 l6">
|
||||
<div class="image-card">
|
||||
<div class="image-preview basil-image">
|
||||
<% if pinned_image.image.attached? %>
|
||||
<%= link_to rails_blob_path(pinned_image.image, disposition: "attachment"), target: '_blank' do %>
|
||||
<%= image_tag rails_blob_path(pinned_image.image, disposition: "attachment"), class: "responsive-img" %>
|
||||
<div class="gallery-grid sortable-gallery" data-content-type="<%= raw_model.class.name %>" data-content-id="<%= raw_model.id %>">
|
||||
<div class="row js-sortable-gallery-items">
|
||||
<!-- Show images in their sorted order -->
|
||||
<% combined_images.each do |image_item| %>
|
||||
<%
|
||||
is_pinned = image_item[:pinned]
|
||||
image_data = image_item[:data]
|
||||
image_type = image_item[:type]
|
||||
image_id = image_item[:id]
|
||||
%>
|
||||
<div class="col s12 m6 l6 gallery-sortable-item" data-image-id="<%= image_id %>" data-image-type="<%= image_type %>">
|
||||
<div class="image-card <%= is_pinned ? 'pinned' : '' %>">
|
||||
<% if image_type == 'image_upload' %>
|
||||
<div class="image-preview">
|
||||
<%= link_to image_data.src(:original), target: '_blank' do %>
|
||||
<%= image_tag image_data.src(:medium), class: "responsive-img" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="image-actions">
|
||||
<div class="image-meta">
|
||||
<span class="file-size"><%= Filesize.from("#{image_data.src_file_size}B").to_f('KB').round(2) %> KB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pin button with tooltip (positioned absolutely) -->
|
||||
<%= link_to toggle_image_pin_path(image_type: 'image_upload', image_id: image_data.id),
|
||||
class: "pin-button-overlay js-toggle-pin #{is_pinned ? 'amber-text' : 'grey-text'} tooltipped",
|
||||
remote: true,
|
||||
method: :post,
|
||||
data: {
|
||||
type: 'json',
|
||||
position: 'top',
|
||||
tooltip: 'Pin this image to always use it in previews of this page'
|
||||
} do %>
|
||||
<i class="material-icons">push_pin</i>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="image-actions">
|
||||
<div class="image-meta">
|
||||
<span class="basil-label">Generated with Basil</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pin button with tooltip (positioned absolutely) -->
|
||||
<%= link_to toggle_image_pin_path(image_type: 'basil_commission', image_id: pinned_image.id),
|
||||
class: "pin-button-overlay js-toggle-pin amber-text tooltipped",
|
||||
remote: true,
|
||||
method: :post,
|
||||
data: {
|
||||
type: 'json',
|
||||
position: 'top',
|
||||
tooltip: 'Pin this image to always use it in previews of this page'
|
||||
} do %>
|
||||
<i class="material-icons">push_pin</i>
|
||||
<% end %>
|
||||
|
||||
<!-- Delete button with tooltip (positioned absolutely) -->
|
||||
<%= link_to basil_delete_path(pinned_image),
|
||||
class: 'delete-button-overlay js-remove-image red-text tooltipped',
|
||||
method: 'delete',
|
||||
remote: true,
|
||||
data: {
|
||||
confirm: "Are you sure? This can't be undone.",
|
||||
position: 'top',
|
||||
tooltip: 'Delete this image'
|
||||
} do %>
|
||||
<i class="material-icons">delete</i>
|
||||
<% end %>
|
||||
|
||||
<div class="pinned-badge-overlay">
|
||||
<i class="material-icons amber-text">push_pin</i> Pinned
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Then show all other regular images -->
|
||||
<% regular_images.each do |image| %>
|
||||
<% next if image == pinned_image %>
|
||||
|
||||
<div class="col s12 m6 l6">
|
||||
<div class="image-card">
|
||||
<div class="image-preview">
|
||||
<%= link_to image.src(:original), target: '_blank' do %>
|
||||
<%= image_tag image.src(:medium), class: "responsive-img" %>
|
||||
|
||||
<!-- Delete button with tooltip (positioned absolutely) -->
|
||||
<%= link_to image_deletion_path(image_data.id),
|
||||
class: 'delete-button-overlay js-remove-image red-text tooltipped',
|
||||
method: 'delete',
|
||||
remote: true,
|
||||
data: {
|
||||
confirm: "Are you sure? This can't be undone.",
|
||||
position: 'top',
|
||||
tooltip: 'Delete this image'
|
||||
} do %>
|
||||
<i class="material-icons">delete</i>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="image-actions">
|
||||
<div class="image-meta">
|
||||
<span class="file-size"><%= Filesize.from("#{image.src_file_size}B").to_f('KB').round(2) %> KB</span>
|
||||
|
||||
<% if is_pinned %>
|
||||
<div class="pinned-badge-overlay">
|
||||
<i class="material-icons amber-text">push_pin</i> Pinned
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="sortable-handle-overlay tooltipped" data-tooltip="Drag to reorder">
|
||||
<i class="material-icons">drag_handle</i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pin button with tooltip (positioned absolutely) -->
|
||||
<%= link_to toggle_image_pin_path(image_type: 'image_upload', image_id: image.id),
|
||||
class: "pin-button-overlay js-toggle-pin #{image.pinned? ? 'amber-text' : 'grey-text'} tooltipped",
|
||||
remote: true,
|
||||
method: :post,
|
||||
data: {
|
||||
type: 'json',
|
||||
position: 'top',
|
||||
tooltip: 'Pin this image to always use it in previews of this page'
|
||||
} do %>
|
||||
<i class="material-icons">push_pin</i>
|
||||
<% end %>
|
||||
|
||||
<!-- Delete button with tooltip (positioned absolutely) -->
|
||||
<%= link_to image_deletion_path(image.id),
|
||||
class: 'delete-button-overlay js-remove-image red-text tooltipped',
|
||||
method: 'delete',
|
||||
remote: true,
|
||||
data: {
|
||||
confirm: "Are you sure? This can't be undone.",
|
||||
position: 'top',
|
||||
tooltip: 'Delete this image'
|
||||
} do %>
|
||||
<i class="material-icons">delete</i>
|
||||
<% end %>
|
||||
|
||||
<% if image.pinned? %>
|
||||
<div class="pinned-badge-overlay">
|
||||
<i class="material-icons amber-text">push_pin</i> Pinned
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Then show all basil images -->
|
||||
<% basil_images.each do |commission| %>
|
||||
<% next if commission == pinned_image %>
|
||||
<div class="col s12 m6 l6">
|
||||
<div class="image-card">
|
||||
<div class="image-preview basil-image">
|
||||
<% if commission.image.attached? %>
|
||||
<%= link_to rails_blob_path(commission.image, disposition: "attachment"), target: '_blank' do %>
|
||||
<%= image_tag rails_blob_path(commission.image, disposition: "attachment"), class: "responsive-img" %>
|
||||
<% else %>
|
||||
<div class="image-preview basil-image">
|
||||
<% if image_data.image.attached? %>
|
||||
<%= link_to rails_blob_path(image_data.image, disposition: "attachment"), target: '_blank' do %>
|
||||
<%= image_tag rails_blob_path(image_data.image, disposition: "attachment"), class: "responsive-img" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="image-actions">
|
||||
<div class="image-meta">
|
||||
<span class="basil-label">Generated with Basil</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pin button with tooltip (positioned absolutely) -->
|
||||
<%= link_to toggle_image_pin_path(image_type: 'basil_commission', image_id: commission.id),
|
||||
class: "pin-button-overlay js-toggle-pin #{commission.pinned? ? 'amber-text' : 'grey-text'} tooltipped",
|
||||
remote: true,
|
||||
method: :post,
|
||||
data: {
|
||||
type: 'json',
|
||||
position: 'top',
|
||||
tooltip: 'Pin this image to always use it in previews of this page'
|
||||
} do %>
|
||||
<i class="material-icons">push_pin</i>
|
||||
<% end %>
|
||||
|
||||
<!-- Delete button with tooltip (positioned absolutely) -->
|
||||
<%= link_to basil_delete_path(commission),
|
||||
class: 'delete-button-overlay js-remove-image red-text tooltipped',
|
||||
method: 'delete',
|
||||
remote: true,
|
||||
data: {
|
||||
confirm: "Are you sure? This can't be undone.",
|
||||
position: 'top',
|
||||
tooltip: 'Delete this image'
|
||||
} do %>
|
||||
<i class="material-icons">delete</i>
|
||||
<% end %>
|
||||
|
||||
<% if commission.pinned? %>
|
||||
<div class="pinned-badge-overlay">
|
||||
<i class="material-icons amber-text">push_pin</i> Pinned
|
||||
<div class="image-actions">
|
||||
<div class="image-meta">
|
||||
<span class="basil-label">Generated with Basil</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pin button with tooltip (positioned absolutely) -->
|
||||
<%= link_to toggle_image_pin_path(image_type: 'basil_commission', image_id: image_data.id),
|
||||
class: "pin-button-overlay js-toggle-pin #{is_pinned ? 'amber-text' : 'grey-text'} tooltipped",
|
||||
remote: true,
|
||||
method: :post,
|
||||
data: {
|
||||
type: 'json',
|
||||
position: 'top',
|
||||
tooltip: 'Pin this image to always use it in previews of this page'
|
||||
} do %>
|
||||
<i class="material-icons">push_pin</i>
|
||||
<% end %>
|
||||
|
||||
<!-- Delete button with tooltip (positioned absolutely) -->
|
||||
<%= link_to basil_delete_path(image_data),
|
||||
class: 'delete-button-overlay js-remove-image red-text tooltipped',
|
||||
method: 'delete',
|
||||
remote: true,
|
||||
data: {
|
||||
confirm: "Are you sure? This can't be undone.",
|
||||
position: 'top',
|
||||
tooltip: 'Delete this image'
|
||||
} do %>
|
||||
<i class="material-icons">delete</i>
|
||||
<% end %>
|
||||
|
||||
<% if is_pinned %>
|
||||
<div class="pinned-badge-overlay">
|
||||
<i class="material-icons amber-text">push_pin</i> Pinned
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="sortable-handle-overlay tooltipped" data-tooltip="Drag to reorder">
|
||||
<i class="material-icons">drag_handle</i>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@ -231,6 +171,55 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Mobile reordering controls -->
|
||||
<div class="mobile-reordering-controls hide-on-med-and-up">
|
||||
<div class="card amber lighten-5">
|
||||
<div class="card-content">
|
||||
<span class="card-title">
|
||||
<i class="material-icons left">touch_app</i>
|
||||
Reorder Images
|
||||
</span>
|
||||
<p>On mobile devices, you can use these controls to change the order of your images:</p>
|
||||
|
||||
<div class="mobile-reordering-instructions">
|
||||
<p><strong>1.</strong> Select an image to move:</p>
|
||||
<select id="mobileImageSelector" class="browser-default">
|
||||
<% combined_images.each_with_index do |image_item, index| %>
|
||||
<%
|
||||
image_data = image_item[:data]
|
||||
image_type = image_item[:type]
|
||||
image_id = image_item[:id]
|
||||
name = image_type == 'image_upload' ? "Image #{index + 1}" : "Basil Image #{index + 1}"
|
||||
name += " (Pinned)" if image_item[:pinned]
|
||||
%>
|
||||
<option value="<%= image_id %>" data-type="<%= image_type %>" data-index="<%= index %>"><%= name %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
|
||||
<p><strong>2.</strong> Choose where to move it:</p>
|
||||
<div class="mobile-move-buttons">
|
||||
<button class="btn-small amber" id="moveImageFirst">
|
||||
<i class="material-icons left">first_page</i>
|
||||
First
|
||||
</button>
|
||||
<button class="btn-small amber" id="moveImageUp">
|
||||
<i class="material-icons left">arrow_upward</i>
|
||||
Up
|
||||
</button>
|
||||
<button class="btn-small amber" id="moveImageDown">
|
||||
<i class="material-icons left">arrow_downward</i>
|
||||
Down
|
||||
</button>
|
||||
<button class="btn-small amber" id="moveImageLast">
|
||||
<i class="material-icons left">last_page</i>
|
||||
Last
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload section -->
|
||||
<div class="upload-section">
|
||||
<h5 class="gallery-section-title">
|
||||
@ -258,7 +247,9 @@
|
||||
</div>
|
||||
|
||||
<div class="upload-button-container center">
|
||||
<%= f.submit "Perform uploads", class: 'btn btn-large waves-effect waves-light' %>
|
||||
<%= f.button :submit, class: 'btn btn-large waves-effect waves-light upload-button', data: { disable_with: '<i class="material-icons left">cloud_upload</i> Uploading...' } do %>
|
||||
<span>Perform uploads</span>
|
||||
<% end %>
|
||||
<p class="upload-note grey-text">
|
||||
Once you've selected your images, press the button above to upload them.
|
||||
This will reload the page.
|
||||
@ -285,6 +276,53 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sortable-handle-overlay {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
cursor: grab;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.sortable-handle-overlay:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.image-card:hover .sortable-handle-overlay {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.ui-sortable-helper {
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.gallery-sortable-item.ui-sortable-placeholder {
|
||||
visibility: visible !important;
|
||||
background-color: rgba(255, 193, 7, 0.2);
|
||||
border: 2px dashed rgba(255, 193, 7, 0.5);
|
||||
border-radius: 8px;
|
||||
margin: 10px;
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
/* Hide sort handles on mobile - they'll use alternative controls */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.sortable-handle-overlay {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-section-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 20px;
|
||||
@ -437,6 +475,27 @@
|
||||
margin: 40px 0 20px;
|
||||
}
|
||||
|
||||
/* Mobile reordering styles */
|
||||
.mobile-reordering-controls {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.mobile-move-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.mobile-move-buttons .btn-small {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pinned-section-divider,
|
||||
.basil-pinned-divider {
|
||||
background-color: #f9f9f9;
|
||||
@ -475,6 +534,67 @@
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize sortable functionality for gallery images
|
||||
var galleryItems = document.querySelector('.js-sortable-gallery-items');
|
||||
if (galleryItems) {
|
||||
var sortableGallery = $('.js-sortable-gallery-items');
|
||||
var sortableContainer = sortableGallery.closest('.sortable-gallery');
|
||||
var contentType = sortableContainer.data('content-type');
|
||||
var contentId = sortableContainer.data('content-id');
|
||||
|
||||
// Initialize the jQuery UI sortable
|
||||
sortableGallery.sortable({
|
||||
items: '.gallery-sortable-item',
|
||||
handle: '.sortable-handle-overlay',
|
||||
placeholder: 'gallery-sortable-item ui-sortable-placeholder',
|
||||
cursor: 'move',
|
||||
opacity: 0.8,
|
||||
tolerance: 'pointer',
|
||||
start: function(e, ui) {
|
||||
// Show reordering instructions when drag starts
|
||||
$('.reordering-instructions').fadeIn();
|
||||
},
|
||||
stop: function(e, ui) {
|
||||
// Hide instructions when drag stops
|
||||
$('.reordering-instructions').fadeOut();
|
||||
|
||||
// Save the new order via API
|
||||
var items = [];
|
||||
$('.gallery-sortable-item').each(function(index) {
|
||||
var imageId = $(this).data('image-id');
|
||||
var imageType = $(this).data('image-type');
|
||||
|
||||
items.push({
|
||||
id: imageId,
|
||||
type: imageType,
|
||||
position: index + 1 // Position is 1-indexed
|
||||
});
|
||||
});
|
||||
|
||||
// Save the new order via AJAX
|
||||
$.ajax({
|
||||
url: '/api/v1/gallery_images/sort',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
images: items,
|
||||
content_type: contentType,
|
||||
content_id: contentId
|
||||
}),
|
||||
success: function(response) {
|
||||
// Show success indicator (optional)
|
||||
M.toast({html: 'Image order saved', classes: 'green'});
|
||||
},
|
||||
error: function(error) {
|
||||
console.error('Error saving image order:', error);
|
||||
M.toast({html: 'Failed to save image order', classes: 'red'});
|
||||
// Could refresh the page here to reset the order
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize tooltips
|
||||
var tooltips = document.querySelectorAll('.tooltipped');
|
||||
M.Tooltip.init(tooltips);
|
||||
@ -548,6 +668,110 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Mobile reordering logic
|
||||
var mobileImageSelector = document.getElementById('mobileImageSelector');
|
||||
var moveFirstBtn = document.getElementById('moveImageFirst');
|
||||
var moveUpBtn = document.getElementById('moveImageUp');
|
||||
var moveDownBtn = document.getElementById('moveImageDown');
|
||||
var moveLastBtn = document.getElementById('moveImageLast');
|
||||
|
||||
if (mobileImageSelector && moveUpBtn && moveDownBtn) {
|
||||
var sortableContainer = document.querySelector('.sortable-gallery');
|
||||
var contentType = sortableContainer ? sortableContainer.getAttribute('data-content-type') : '';
|
||||
var contentId = sortableContainer ? sortableContainer.getAttribute('data-content-id') : '';
|
||||
|
||||
// Helper function to reorder images
|
||||
function reorderImages(getNewPosition) {
|
||||
if (!mobileImageSelector.value) return;
|
||||
|
||||
var selectedOption = mobileImageSelector.options[mobileImageSelector.selectedIndex];
|
||||
var imageId = mobileImageSelector.value;
|
||||
var imageType = selectedOption.getAttribute('data-type');
|
||||
var currentIndex = parseInt(selectedOption.getAttribute('data-index'));
|
||||
|
||||
// Get all items
|
||||
var items = Array.from(document.querySelectorAll('.gallery-sortable-item'));
|
||||
var totalItems = items.length;
|
||||
|
||||
// Calculate new position based on the operation
|
||||
var newPosition = getNewPosition(currentIndex, totalItems);
|
||||
|
||||
// Update DOM order
|
||||
var itemToMove = items[currentIndex];
|
||||
var parent = itemToMove.parentNode;
|
||||
|
||||
if (newPosition === 0) {
|
||||
// Move to first position
|
||||
parent.insertBefore(itemToMove, parent.firstChild);
|
||||
} else if (newPosition >= totalItems - 1) {
|
||||
// Move to last position
|
||||
parent.appendChild(itemToMove);
|
||||
} else {
|
||||
// Move to specific position
|
||||
parent.insertBefore(itemToMove, items[newPosition]);
|
||||
}
|
||||
|
||||
// Update data-index attributes after reordering
|
||||
Array.from(document.querySelectorAll('.gallery-sortable-item')).forEach((item, idx) => {
|
||||
var option = Array.from(mobileImageSelector.options).find(opt =>
|
||||
opt.value === item.getAttribute('data-image-id') &&
|
||||
opt.getAttribute('data-type') === item.getAttribute('data-image-type')
|
||||
);
|
||||
if (option) option.setAttribute('data-index', idx);
|
||||
});
|
||||
|
||||
// Prepare data for API update
|
||||
var updatedItems = [];
|
||||
Array.from(document.querySelectorAll('.gallery-sortable-item')).forEach((item, idx) => {
|
||||
updatedItems.push({
|
||||
id: item.getAttribute('data-image-id'),
|
||||
type: item.getAttribute('data-image-type'),
|
||||
position: idx + 1 // Position is 1-indexed
|
||||
});
|
||||
});
|
||||
|
||||
// Save via API
|
||||
fetch('/api/v1/gallery_images/sort', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
images: updatedItems,
|
||||
content_type: contentType,
|
||||
content_id: contentId
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Success toast
|
||||
M.toast({html: 'Image order saved', classes: 'green'});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error saving image order:', error);
|
||||
M.toast({html: 'Failed to save image order', classes: 'red'});
|
||||
});
|
||||
}
|
||||
|
||||
// Set up event listeners for mobile reordering buttons
|
||||
moveFirstBtn.addEventListener('click', function() {
|
||||
reorderImages(() => 0);
|
||||
});
|
||||
|
||||
moveUpBtn.addEventListener('click', function() {
|
||||
reorderImages((currentIndex) => Math.max(0, currentIndex - 1));
|
||||
});
|
||||
|
||||
moveDownBtn.addEventListener('click', function() {
|
||||
reorderImages((currentIndex, totalItems) => Math.min(totalItems - 1, currentIndex + 1));
|
||||
});
|
||||
|
||||
moveLastBtn.addEventListener('click', function() {
|
||||
reorderImages((currentIndex, totalItems) => totalItems - 1);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle delete buttons
|
||||
var deleteButtons = document.querySelectorAll('.js-remove-image');
|
||||
|
||||
|
||||
@ -79,7 +79,8 @@
|
||||
combined_images << {
|
||||
type: 'regular',
|
||||
data: image,
|
||||
created_at: image.created_at
|
||||
created_at: image.created_at,
|
||||
position: image.position || 999999
|
||||
}
|
||||
end
|
||||
|
||||
@ -87,12 +88,13 @@
|
||||
combined_images << {
|
||||
type: 'basil',
|
||||
data: commission,
|
||||
created_at: commission.saved_at
|
||||
created_at: commission.saved_at,
|
||||
position: commission.position || 999999
|
||||
}
|
||||
end
|
||||
|
||||
# Randomize the order to mix different image sources
|
||||
combined_images.shuffle!
|
||||
# First sort by position (using pinned status as priority)
|
||||
combined_images.sort_by! { |img| [(img[:data].pinned ? 0 : 1), img[:position] || 999999, img[:created_at] || Time.now] }
|
||||
%>
|
||||
|
||||
<div class="gallery-grid" id="galleryGrid">
|
||||
|
||||
@ -110,7 +110,7 @@
|
||||
<% @current_user_content.except('Universe').values.flatten.sort_by { |p| p.name.downcase }.each do |content_page| %>
|
||||
<div class="col s12 m12 l6">
|
||||
<%= link_to send("edit_#{content_page.page_type.downcase}_path", content_page.id) do %>
|
||||
<div class="hoverable card" style="margin-bottom: 2px">
|
||||
<div class="hoverable card <%= content_page.color %>" style="margin-bottom: 2px">
|
||||
<div class="card-image">
|
||||
<%= image_tag content_page.random_image_including_private, style: 'height: 245px' %>
|
||||
<div class="card-title bordered-text">
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
# rubocop:disable LineLength
|
||||
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'
|
||||
end
|
||||
end
|
||||
default_url_options :host => "notebook.ai"
|
||||
|
||||
scope :ai, path: '/ai' do
|
||||
@ -258,6 +265,7 @@ Rails.application.routes.draw do
|
||||
get :timelines, on: :member
|
||||
|
||||
get :changelog, on: :member
|
||||
get :gallery, on: :member
|
||||
get :toggle_archive, on: :member
|
||||
post :toggle_favorite, on: :member
|
||||
get '/tagged/:slug', action: :index, on: :collection, as: :page_tag
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
class AddPaperclipColumnsToImageUploads < ActiveRecord::Migration[6.1]
|
||||
# This is a super janky, sus migration. Paperclip is already working just fine in production,
|
||||
# but errors on multiple new development machines due to allegedly not having these columns in
|
||||
# the DB schema. So... we're adding them here, but only if they don't already exist.
|
||||
|
||||
def up
|
||||
# Only add columns if they don't already exist
|
||||
add_column :image_uploads, :src_file_name, :string unless column_exists?(:image_uploads, :src_file_name)
|
||||
add_column :image_uploads, :src_content_type, :string unless column_exists?(:image_uploads, :src_content_type)
|
||||
add_column :image_uploads, :src_file_size, :integer unless column_exists?(:image_uploads, :src_file_size)
|
||||
add_column :image_uploads, :src_updated_at, :datetime unless column_exists?(:image_uploads, :src_updated_at)
|
||||
end
|
||||
|
||||
def down
|
||||
# Only remove columns if they exist
|
||||
remove_column :image_uploads, :src_file_name if column_exists?(:image_uploads, :src_file_name)
|
||||
remove_column :image_uploads, :src_content_type if column_exists?(:image_uploads, :src_content_type)
|
||||
remove_column :image_uploads, :src_file_size if column_exists?(:image_uploads, :src_file_size)
|
||||
remove_column :image_uploads, :src_updated_at if column_exists?(:image_uploads, :src_updated_at)
|
||||
end
|
||||
end
|
||||
37
db/migrate/20250626212442_add_position_to_image_uploads.rb
Normal file
37
db/migrate/20250626212442_add_position_to_image_uploads.rb
Normal file
@ -0,0 +1,37 @@
|
||||
class AddPositionToImageUploads < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :image_uploads, :position, :integer
|
||||
add_index :image_uploads, [:content_type, :content_id, :position]
|
||||
|
||||
# Backfill existing image_uploads with positions based on creation date
|
||||
reversible do |dir|
|
||||
dir.up do
|
||||
if connection.adapter_name.downcase.include?('postgres')
|
||||
# PostgreSQL-specific approach using window functions
|
||||
execute <<-SQL
|
||||
UPDATE image_uploads
|
||||
SET position = t.seq
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY content_type, content_id
|
||||
ORDER BY created_at ASC, id ASC
|
||||
) as seq
|
||||
FROM image_uploads
|
||||
) as t
|
||||
WHERE image_uploads.id = t.id
|
||||
SQL
|
||||
else
|
||||
# Database-agnostic approach using Ruby
|
||||
say_with_time("Backfilling positions for ImageUploads") do
|
||||
ImageUpload.group_by { |img| [img.content_type, img.content_id] }.each do |group_key, images|
|
||||
ordered_images = images.sort_by { |img| [img.created_at || Time.current, img.id] }
|
||||
ordered_images.each_with_index do |img, idx|
|
||||
img.update_column(:position, idx + 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,38 @@
|
||||
class AddPositionToBasilCommissions < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :basil_commissions, :position, :integer
|
||||
add_index :basil_commissions, [:entity_type, :entity_id, :position], name: 'index_basil_commissions_on_entity_position'
|
||||
|
||||
# Backfill existing basil_commissions with positions based on save date
|
||||
reversible do |dir|
|
||||
dir.up do
|
||||
if connection.adapter_name.downcase.include?('postgres')
|
||||
# PostgreSQL-specific approach using window functions
|
||||
execute <<-SQL
|
||||
UPDATE basil_commissions
|
||||
SET position = t.seq
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY entity_type, entity_id
|
||||
ORDER BY saved_at ASC, id ASC
|
||||
) as seq
|
||||
FROM basil_commissions
|
||||
WHERE saved_at IS NOT NULL
|
||||
) as t
|
||||
WHERE basil_commissions.id = t.id
|
||||
SQL
|
||||
else
|
||||
# Database-agnostic approach using Ruby
|
||||
say_with_time("Backfilling positions for BasilCommissions") do
|
||||
BasilCommission.where.not(saved_at: nil).group_by { |img| [img.entity_type, img.entity_id] }.each do |group_key, images|
|
||||
ordered_images = images.sort_by { |img| [img.saved_at || Time.current, img.id] }
|
||||
ordered_images.each_with_index do |img, idx|
|
||||
img.update_column(:position, idx + 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
10
db/schema.rb
10
db/schema.rb
@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2025_06_25_182655) do
|
||||
ActiveRecord::Schema.define(version: 2025_06_26_212529) do
|
||||
|
||||
create_table "active_storage_attachments", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
@ -203,7 +203,9 @@ ActiveRecord::Schema.define(version: 2025_06_25_182655) do
|
||||
t.datetime "deleted_at"
|
||||
t.integer "basil_version", default: 2
|
||||
t.boolean "pinned", default: false
|
||||
t.integer "position"
|
||||
t.index ["entity_type", "entity_id", "pinned"], name: "index_basil_commissions_on_entity_pinned"
|
||||
t.index ["entity_type", "entity_id", "position"], name: "index_basil_commissions_on_entity_position"
|
||||
t.index ["entity_type", "entity_id", "saved_at"], name: "basil_commissions_ees"
|
||||
t.index ["entity_type", "entity_id", "style"], name: "basil_commissions_ees2"
|
||||
t.index ["entity_type", "entity_id"], name: "basil_commissions_ee"
|
||||
@ -1674,7 +1676,13 @@ ActiveRecord::Schema.define(version: 2025_06_25_182655) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "src"
|
||||
t.boolean "pinned", default: false
|
||||
t.string "src_file_name"
|
||||
t.string "src_content_type"
|
||||
t.integer "src_file_size"
|
||||
t.datetime "src_updated_at"
|
||||
t.integer "position"
|
||||
t.index ["content_type", "content_id", "pinned"], name: "index_image_uploads_on_content_pinned"
|
||||
t.index ["content_type", "content_id", "position"], name: "index_image_uploads_on_content_type_and_content_id_and_position"
|
||||
t.index ["content_type", "content_id"], name: "index_image_uploads_on_content_type_and_content_id"
|
||||
t.index ["user_id"], name: "index_image_uploads_on_user_id"
|
||||
end
|
||||
|
||||
Loading…
Reference in New Issue
Block a user