galleries cleanup & image reordering

This commit is contained in:
Andrew Brown 2025-06-26 12:21:38 -07:00
parent 0f57473bd9
commit 307bb4137d
15 changed files with 658 additions and 216 deletions

View 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

View File

@ -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

View File

@ -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? }

View File

@ -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

View File

@ -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? }

View File

@ -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

View File

@ -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? %>

View File

@ -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');

View File

@ -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">

View File

@ -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">

View File

@ -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

View File

@ -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

View 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

View File

@ -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

View File

@ -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