Skip to content

Commit f3e4fab

Browse files
authored
Merge pull request #1391 from indentlabs/new-gallery
July release
2 parents f12009f + c42a55c commit f3e4fab

31 files changed

+3748
-438
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
class Api::V1::GalleryImagesController < ApplicationController
2+
before_action :authenticate_user!
3+
skip_before_action :verify_authenticity_token, only: [:sort]
4+
5+
# POST /api/v1/gallery_images/sort
6+
# Handles sorting of gallery images (both ImageUploads and BasilCommissions)
7+
# Expected params:
8+
# - images: Array of hashes with:
9+
# - id: Image ID
10+
# - type: 'image_upload' or 'basil_commission'
11+
# - position: New position
12+
# - content_type: Content type (e.g., 'Character')
13+
# - content_id: Content ID
14+
def sort
15+
# Validate ownership/contribution permissions for the content
16+
content_type = params[:content_type]
17+
content_id = params[:content_id]
18+
content = content_type.constantize.find_by(id: content_id)
19+
20+
# Check permissions - must own or contribute to this content
21+
unless content &&
22+
(content.user_id == current_user.id ||
23+
(content.respond_to?(:universe_id) &&
24+
content.universe_id.present? &&
25+
current_user.contributable_universe_ids.include?(content.universe_id)))
26+
return render json: { error: 'Unauthorized' }, status: :unauthorized
27+
end
28+
29+
# Process each image in the array
30+
success = true
31+
32+
# Add a validation check to ensure all images in params exist
33+
# This helps prevent race conditions where an image might have been deleted
34+
image_ids_by_type = {
35+
'image_upload' => [],
36+
'basil_commission' => []
37+
}
38+
39+
params[:images].each do |image_data|
40+
if ['image_upload', 'basil_commission'].include?(image_data[:type])
41+
image_ids_by_type[image_data[:type]] << image_data[:id].to_i
42+
else
43+
success = false
44+
return render json: { error: 'Invalid image type' }, status: :unprocessable_entity
45+
end
46+
end
47+
48+
# Verify all images exist and belong to this content
49+
image_upload_count = ImageUpload.where(
50+
id: image_ids_by_type['image_upload'],
51+
content_type: content_type,
52+
content_id: content_id
53+
).count
54+
55+
basil_commission_count = BasilCommission.where(
56+
id: image_ids_by_type['basil_commission'],
57+
entity_type: content_type,
58+
entity_id: content_id
59+
).count
60+
61+
if image_upload_count != image_ids_by_type['image_upload'].size ||
62+
basil_commission_count != image_ids_by_type['basil_commission'].size
63+
return render json: { error: 'Some images do not exist or do not belong to this content' },
64+
status: :unprocessable_entity
65+
end
66+
67+
# Use an advisory lock to prevent concurrent updates
68+
# This ensures only one request at a time can update positions for this content
69+
lock_key = "gallery_images_#{content_type}_#{content_id}"
70+
71+
# Use a transaction to ensure all positions are updated or none are
72+
ActiveRecord::Base.transaction do
73+
# With_advisory_lock is a gem method, but we handle it with our own locking mechanism
74+
# if it's not available
75+
if ActiveRecord::Base.connection.respond_to?(:with_advisory_lock)
76+
ActiveRecord::Base.connection.with_advisory_lock(lock_key) do
77+
update_image_positions(params[:images], content_type, content_id)
78+
end
79+
else
80+
# Fallback to regular transaction if advisory locks aren't supported
81+
update_image_positions(params[:images], content_type, content_id)
82+
end
83+
end
84+
85+
if success
86+
render json: { success: true }
87+
else
88+
render json: { error: 'Failed to update image positions' }, status: :unprocessable_entity
89+
end
90+
end
91+
92+
private
93+
94+
def update_image_positions(images, content_type, content_id)
95+
# Process each image in the array
96+
images.each do |image_data|
97+
if image_data[:type] == 'image_upload'
98+
# Update ImageUpload position
99+
image = ImageUpload.find_by(id: image_data[:id])
100+
if image && image.content_type == content_type && image.content_id.to_s == content_id.to_s
101+
image.insert_at(image_data[:position].to_i)
102+
else
103+
raise ActiveRecord::Rollback
104+
end
105+
elsif image_data[:type] == 'basil_commission'
106+
# Update BasilCommission position
107+
image = BasilCommission.find_by(id: image_data[:id])
108+
if image && image.entity_type == content_type && image.entity_id.to_s == content_id.to_s
109+
image.insert_at(image_data[:position].to_i)
110+
else
111+
raise ActiveRecord::Rollback
112+
end
113+
else
114+
raise ActiveRecord::Rollback
115+
end
116+
end
117+
end
118+
end

app/controllers/browse_controller.rb

Lines changed: 103 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ class BrowseController < ApplicationController
33

44
def tag
55
@tag_slug = params[:tag_slug]
6-
# Debug logging
7-
Rails.logger.info "BrowseController#tag - Looking for content with tag slug: #{@tag_slug}"
86

97
# For now, only allow ArtFight2025 tag to be browsed (case insensitive)
108
unless @tag_slug.downcase == 'artfight2025'
@@ -18,122 +16,125 @@ def tag
1816

1917
# Directly check database for any pages with this tag
2018
tag_exists = PageTag.exists?(slug: @tag_slug)
21-
Rails.logger.info "Tag exists in PageTag table? #{tag_exists}"
2219

2320
if tag_exists
24-
sample_tag = PageTag.find_by(slug: @tag_slug)
25-
Rails.logger.info "Sample tag: #{sample_tag.inspect}"
26-
end
27-
28-
# Go through each content type and find public items with this tag
29-
Rails.application.config.content_types[:all].each do |content_type|
30-
# Try a different query approach - first find page IDs with this tag
31-
tag_page_ids = PageTag.where(page_type: content_type.name, slug: @tag_slug).pluck(:page_id)
21+
# Calculate a daily seed for consistent randomization across page refreshes in same day
22+
# This allows for caching while still changing the order daily
23+
daily_seed = Date.today.to_time.to_i
3224

33-
if tag_page_ids.any?
34-
Rails.logger.info "Found #{tag_page_ids.count} #{content_type.name} page IDs with tag: #{tag_page_ids}"
35-
content_pages = content_type.where(id: tag_page_ids).where(privacy: 'public').order(:name)
36-
else
37-
Rails.logger.info "No #{content_type.name} pages found with tag"
38-
content_pages = content_type.none
39-
end
25+
# Number of items to show per content type to avoid performance issues
26+
per_type_limit = 50
27+
28+
# Go through each content type and find public items with this tag
29+
Rails.application.config.content_types[:all].each do |content_type|
30+
# First find page IDs with this tag
31+
tag_page_ids = PageTag.where(page_type: content_type.name, slug: @tag_slug).pluck(:page_id)
32+
33+
if tag_page_ids.any?
34+
# Use database-level randomization with the daily seed for caching potential
35+
# Use PostgreSQL's random function with a seed derived from the ID and daily seed
36+
# This is much more efficient than loading all records into memory
37+
content_pages = content_type.where(id: tag_page_ids)
38+
.where(privacy: 'public')
39+
.order(Arel.sql("RANDOM()"))
40+
.limit(per_type_limit)
4041

41-
@tagged_content << {
42-
type: content_type.name,
43-
icon: content_type.icon,
44-
color: content_type.color,
45-
content: content_pages
46-
} if content_pages.any?
47-
end
48-
49-
# Add documents separately since they don't use the common content type structure
50-
document_tag_page_ids = PageTag.where(page_type: 'Document', slug: @tag_slug).pluck(:page_id)
51-
if document_tag_page_ids.any?
52-
Rails.logger.info "Found #{document_tag_page_ids.count} Document page IDs with tag: #{document_tag_page_ids}"
53-
documents = Document.where(id: document_tag_page_ids).where(privacy: 'public').order(:title)
54-
else
55-
Rails.logger.info "No Document pages found with tag"
56-
documents = Document.none
57-
end # Documents use 'title' instead of 'name'
58-
59-
@tagged_content << {
60-
type: 'Document',
61-
icon: 'description',
62-
color: 'blue',
63-
content: documents
64-
} if documents.any?
65-
66-
# Add timelines separately since they don't use the common content type structure
67-
timeline_tag_page_ids = PageTag.where(page_type: 'Timeline', slug: @tag_slug).pluck(:page_id)
68-
if timeline_tag_page_ids.any?
69-
Rails.logger.info "Found #{timeline_tag_page_ids.count} Timeline page IDs with tag: #{timeline_tag_page_ids}"
70-
timelines = Timeline.where(id: timeline_tag_page_ids).where(privacy: 'public').order(:name)
71-
else
72-
Rails.logger.info "No Timeline pages found with tag"
73-
timelines = Timeline.none
74-
end
75-
76-
@tagged_content << {
77-
type: 'Timeline',
78-
icon: 'timeline',
79-
color: 'blue',
80-
content: timelines
81-
} if timelines.any?
82-
83-
# Get images for content cards from all users
84-
content_ids_by_type = {}
85-
user_ids = []
86-
87-
@tagged_content.each do |content_group|
88-
content_group[:content].each do |content|
89-
content_type = content.class.name
90-
content_ids_by_type[content_type] ||= []
91-
content_ids_by_type[content_type] << content.id
92-
user_ids << content.user_id
42+
@tagged_content << {
43+
type: content_type.name,
44+
icon: content_type.icon,
45+
color: content_type.color,
46+
content: content_pages
47+
} if content_pages.any?
48+
end
9349
end
94-
end
95-
96-
# Get unique user IDs
97-
user_ids.uniq!
98-
99-
# Get all relevant images
100-
@random_image_pool_cache = {}
101-
if content_ids_by_type.any?
102-
content_ids_by_type.each do |content_type, ids|
103-
images = ImageUpload.where(content_type: content_type, content_id: ids)
50+
51+
# Add documents separately since they don't use the common content type structure
52+
document_tag_page_ids = PageTag.where(page_type: 'Document', slug: @tag_slug).pluck(:page_id)
53+
if document_tag_page_ids.any?
54+
documents = Document.where(id: document_tag_page_ids)
55+
.where(privacy: 'public')
56+
.order(Arel.sql("RANDOM()"))
57+
.limit(per_type_limit)
10458

105-
images.each do |image|
106-
key = [image.content_type, image.content_id]
107-
@random_image_pool_cache[key] ||= []
108-
@random_image_pool_cache[key] << image
59+
@tagged_content << {
60+
type: 'Document',
61+
icon: 'description',
62+
color: 'blue',
63+
content: documents
64+
} if documents.any?
65+
end
66+
67+
# Add timelines separately since they don't use the common content type structure
68+
timeline_tag_page_ids = PageTag.where(page_type: 'Timeline', slug: @tag_slug).pluck(:page_id)
69+
if timeline_tag_page_ids.any?
70+
timelines = Timeline.where(id: timeline_tag_page_ids)
71+
.where(privacy: 'public')
72+
.order(Arel.sql("RANDOM()"))
73+
.limit(per_type_limit)
74+
75+
@tagged_content << {
76+
type: 'Timeline',
77+
icon: 'timeline',
78+
color: 'blue',
79+
content: timelines
80+
} if timelines.any?
81+
end
82+
83+
# Get images for content cards from all users
84+
content_ids_by_type = {}
85+
user_ids = []
86+
87+
@tagged_content.each do |content_group|
88+
content_group[:content].each do |content|
89+
content_type = content.class.name
90+
content_ids_by_type[content_type] ||= []
91+
content_ids_by_type[content_type] << content.id
92+
user_ids << content.user_id
10993
end
11094
end
111-
end
112-
113-
# Initialize basil commissions if there are any content items
114-
if content_ids_by_type.any?
115-
entity_types = []
116-
entity_ids = []
11795

118-
content_ids_by_type.each do |content_type, ids|
119-
entity_type = content_type.downcase.pluralize
120-
entity_types.concat([entity_type] * ids.length)
121-
entity_ids.concat(ids)
96+
# Get unique user IDs
97+
user_ids.uniq!
98+
99+
# Get all relevant images - optimize with a single query per content type
100+
@random_image_pool_cache = {}
101+
if content_ids_by_type.any?
102+
content_ids_by_type.each do |content_type, ids|
103+
images = ImageUpload.where(content_type: content_type, content_id: ids)
104+
105+
images.each do |image|
106+
key = [image.content_type, image.content_id]
107+
@random_image_pool_cache[key] ||= []
108+
@random_image_pool_cache[key] << image
109+
end
110+
end
111+
end
112+
113+
# Initialize basil commissions if there are any content items
114+
if content_ids_by_type.any?
115+
entity_types = []
116+
entity_ids = []
117+
118+
content_ids_by_type.each do |content_type, ids|
119+
entity_type = content_type.downcase.pluralize
120+
entity_types.concat([entity_type] * ids.length)
121+
entity_ids.concat(ids)
122+
end
123+
124+
@saved_basil_commissions = BasilCommission.where(
125+
entity_type: entity_types,
126+
entity_id: entity_ids
127+
).where.not(saved_at: nil)
128+
.group_by { |commission| [commission.entity_type, commission.entity_id] }
122129
end
123130

124-
@saved_basil_commissions = BasilCommission.where(
125-
entity_type: entity_types,
126-
entity_id: entity_ids
127-
).where.not(saved_at: nil)
128-
.group_by { |commission| [commission.entity_type, commission.entity_id] }
131+
# Get usernames for display with content - optimize with a single query
132+
@users_cache = User.where(id: user_ids).index_by(&:id)
129133
end
130134

131135
# Set a default accent color for the page
132136
@accent_color = 'purple'
133137

134-
# Get usernames for display with content
135-
@users_cache = User.where(id: user_ids).index_by(&:id)
136-
137138
# Sort content types so Characters always appear first
138139
@tagged_content = @tagged_content.sort_by do |content_group|
139140
if content_group[:type] == 'Character'

0 commit comments

Comments
 (0)