beautiful_photons 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +11 -0
  5. data/app/assets/javascripts/beautiful_photons/bulk_select_controller.js +76 -0
  6. data/app/assets/javascripts/beautiful_photons/crop_editor_controller.js +108 -0
  7. data/app/assets/javascripts/beautiful_photons/focal_point_controller.js +16 -0
  8. data/app/assets/javascripts/beautiful_photons/index.js +13 -0
  9. data/app/assets/javascripts/beautiful_photons/reorder_controller.js +45 -0
  10. data/app/assets/stylesheets/beautiful_photons/application.css +15 -0
  11. data/app/controllers/beautiful_photons/admin/base_controller.rb +10 -0
  12. data/app/controllers/beautiful_photons/admin/categories_controller.rb +37 -0
  13. data/app/controllers/beautiful_photons/admin/galleries_controller.rb +100 -0
  14. data/app/controllers/beautiful_photons/admin/photos_controller.rb +75 -0
  15. data/app/controllers/beautiful_photons/admin/standalones_controller.rb +54 -0
  16. data/app/controllers/beautiful_photons/api/base_controller.rb +15 -0
  17. data/app/controllers/beautiful_photons/api/v1/galleries_controller.rb +60 -0
  18. data/app/controllers/beautiful_photons/api/v1/gallery_photos_controller.rb +60 -0
  19. data/app/controllers/beautiful_photons/api/v1/photos_controller.rb +65 -0
  20. data/app/controllers/beautiful_photons/application_controller.rb +4 -0
  21. data/app/controllers/beautiful_photons/standalone_photos_controller.rb +16 -0
  22. data/app/helpers/beautiful_photons/application_helper.rb +4 -0
  23. data/app/helpers/beautiful_photons/photo_helper.rb +103 -0
  24. data/app/jobs/beautiful_photons/application_job.rb +4 -0
  25. data/app/mailers/beautiful_photons/application_mailer.rb +6 -0
  26. data/app/models/beautiful_photons/application_record.rb +5 -0
  27. data/app/models/beautiful_photons/category.rb +7 -0
  28. data/app/models/beautiful_photons/gallery.rb +17 -0
  29. data/app/models/beautiful_photons/gallery_photo.rb +22 -0
  30. data/app/models/beautiful_photons/photo.rb +29 -0
  31. data/app/models/beautiful_photons/standalone.rb +23 -0
  32. data/app/views/beautiful_photons/admin/categories/index.html.erb +28 -0
  33. data/app/views/beautiful_photons/admin/galleries/add_photos_page.html.erb +30 -0
  34. data/app/views/beautiful_photons/admin/galleries/edit.html.erb +17 -0
  35. data/app/views/beautiful_photons/admin/galleries/index.html.erb +13 -0
  36. data/app/views/beautiful_photons/admin/galleries/new.html.erb +17 -0
  37. data/app/views/beautiful_photons/admin/galleries/show.html.erb +84 -0
  38. data/app/views/beautiful_photons/admin/photos/edit.html.erb +35 -0
  39. data/app/views/beautiful_photons/admin/photos/index.html.erb +60 -0
  40. data/app/views/beautiful_photons/admin/photos/new.html.erb +10 -0
  41. data/app/views/beautiful_photons/admin/photos/show.html.erb +66 -0
  42. data/app/views/beautiful_photons/admin/standalones/change_photo.html.erb +39 -0
  43. data/app/views/beautiful_photons/admin/standalones/edit_crop.html.erb +38 -0
  44. data/app/views/beautiful_photons/admin/standalones/edit_mobile_crop.html.erb +37 -0
  45. data/app/views/beautiful_photons/admin/standalones/index.html.erb +27 -0
  46. data/app/views/beautiful_photons/admin/standalones/show.html.erb +52 -0
  47. data/app/views/beautiful_photons/standalone_photos/show.html.erb +27 -0
  48. data/app/views/layouts/beautiful_photons/admin.html.erb +37 -0
  49. data/config/importmap.rb +5 -0
  50. data/config/routes.rb +36 -0
  51. data/db/migrate/20260318000001_create_beautiful_photons_photos.rb +15 -0
  52. data/db/migrate/20260318000002_create_beautiful_photons_galleries.rb +13 -0
  53. data/db/migrate/20260318000003_create_beautiful_photons_gallery_photos.rb +15 -0
  54. data/db/migrate/20260318000004_create_beautiful_photons_standalones.rb +13 -0
  55. data/db/migrate/20260318000005_add_crop_to_beautiful_photons_standalones.rb +12 -0
  56. data/db/migrate/20260318185017_create_beautiful_photons_categories.rb +11 -0
  57. data/db/migrate/20260318194125_add_category_to_beautiful_photons_photos.rb +6 -0
  58. data/lib/beautiful_photons/engine.rb +30 -0
  59. data/lib/beautiful_photons/version.rb +3 -0
  60. data/lib/beautiful_photons.rb +31 -0
  61. data/lib/generators/beautiful_photons/install_generator.rb +24 -0
  62. data/lib/tasks/beautiful_photons_tasks.rake +4 -0
  63. metadata +147 -0
@@ -0,0 +1,60 @@
1
+ module BeautifulPhotons
2
+ module Api
3
+ module V1
4
+ class GalleriesController < Api::BaseController
5
+ def index
6
+ galleries = Gallery.all
7
+ render json: galleries.map { |gallery| gallery_json(gallery) }
8
+ end
9
+
10
+ def show
11
+ gallery = Gallery.find(params[:id])
12
+ render json: gallery_json(gallery)
13
+ end
14
+
15
+ def destroy
16
+ gallery = Gallery.find(params[:id])
17
+ gallery.destroy!
18
+ head :no_content
19
+ end
20
+
21
+ def update
22
+ gallery = Gallery.find(params[:id])
23
+
24
+ if gallery.update(gallery_params)
25
+ render json: gallery_json(gallery)
26
+ else
27
+ render json: { errors: gallery.errors.full_messages }, status: :unprocessable_entity
28
+ end
29
+ end
30
+
31
+ def create
32
+ gallery = Gallery.new(gallery_params)
33
+
34
+ if gallery.save
35
+ render json: gallery_json(gallery), status: :created
36
+ else
37
+ render json: { errors: gallery.errors.full_messages }, status: :unprocessable_entity
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def gallery_params
44
+ params.require(:gallery).permit(:name, :title, :description)
45
+ end
46
+
47
+ def gallery_json(gallery)
48
+ {
49
+ id: gallery.id,
50
+ name: gallery.name,
51
+ title: gallery.title,
52
+ description: gallery.description,
53
+ created_at: gallery.created_at,
54
+ updated_at: gallery.updated_at
55
+ }
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,60 @@
1
+ module BeautifulPhotons
2
+ module Api
3
+ module V1
4
+ class GalleryPhotosController < Api::BaseController
5
+ def index
6
+ gallery = Gallery.find(params[:gallery_id])
7
+ gallery_photos = gallery.gallery_photos.order(:position)
8
+ render json: gallery_photos.map { |gp| gallery_photo_json(gp) }
9
+ end
10
+
11
+ def create
12
+ gallery = Gallery.find(params[:gallery_id])
13
+ gallery_photo = gallery.gallery_photos.new(gallery_photo_params)
14
+
15
+ if gallery_photo.save
16
+ render json: gallery_photo_json(gallery_photo), status: :created
17
+ else
18
+ render json: { errors: gallery_photo.errors.full_messages }, status: :unprocessable_entity
19
+ end
20
+ end
21
+
22
+ def destroy
23
+ gallery = Gallery.find(params[:gallery_id])
24
+ gallery_photo = gallery.gallery_photos.find(params[:id])
25
+ gallery_photo.destroy!
26
+ head :no_content
27
+ end
28
+
29
+ def update
30
+ gallery = Gallery.find(params[:gallery_id])
31
+ gallery_photo = gallery.gallery_photos.find(params[:id])
32
+
33
+ if gallery_photo.update(gallery_photo_params.except(:photo_id))
34
+ render json: gallery_photo_json(gallery_photo)
35
+ else
36
+ render json: { errors: gallery_photo.errors.full_messages }, status: :unprocessable_entity
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def gallery_photo_params
43
+ params.require(:gallery_photo).permit(:photo_id, :position, :category)
44
+ end
45
+
46
+ def gallery_photo_json(gallery_photo)
47
+ {
48
+ id: gallery_photo.id,
49
+ gallery_id: gallery_photo.gallery_id,
50
+ photo_id: gallery_photo.photo_id,
51
+ position: gallery_photo.position,
52
+ category: gallery_photo.category,
53
+ created_at: gallery_photo.created_at,
54
+ updated_at: gallery_photo.updated_at
55
+ }
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,65 @@
1
+ module BeautifulPhotons
2
+ module Api
3
+ module V1
4
+ class PhotosController < Api::BaseController
5
+ def index
6
+ photos = Photo.all
7
+ render json: photos.map { |photo| photo_json(photo) }
8
+ end
9
+
10
+ def show
11
+ photo = Photo.find(params[:id])
12
+ render json: photo_json(photo)
13
+ end
14
+
15
+ def update
16
+ photo = Photo.find(params[:id])
17
+
18
+ if photo.update(photo_params)
19
+ render json: photo_json(photo)
20
+ else
21
+ render json: { errors: photo.errors.full_messages }, status: :unprocessable_entity
22
+ end
23
+ end
24
+
25
+ def destroy
26
+ photo = Photo.find(params[:id])
27
+ photo.destroy!
28
+ head :no_content
29
+ end
30
+
31
+ def create
32
+ photo = Photo.new(photo_params)
33
+
34
+ if photo.save
35
+ render json: photo_json(photo), status: :created
36
+ else
37
+ render json: { errors: photo.errors.full_messages }, status: :unprocessable_entity
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def photo_params
44
+ params.require(:photo).permit(:title, :description, :image, :focal_x, :focal_y,
45
+ :mobile_focal_x, :mobile_focal_y, :published)
46
+ end
47
+
48
+ def photo_json(photo)
49
+ {
50
+ id: photo.id,
51
+ title: photo.title,
52
+ description: photo.description,
53
+ focal_x: photo.focal_x.to_f,
54
+ focal_y: photo.focal_y.to_f,
55
+ mobile_focal_x: photo.mobile_focal_x&.to_f,
56
+ mobile_focal_y: photo.mobile_focal_y&.to_f,
57
+ published: photo.published,
58
+ created_at: photo.created_at,
59
+ updated_at: photo.updated_at
60
+ }
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,4 @@
1
+ module BeautifulPhotons
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,16 @@
1
+ module BeautifulPhotons
2
+ class StandalonePhotosController < ApplicationController
3
+ def show
4
+ @standalone = Standalone.find_by!(key: params[:key])
5
+ @photo = @standalone.photo
6
+
7
+ if @photo&.image&.attached?
8
+ if stale?(etag: [ @standalone, @photo ], last_modified: [ @standalone.updated_at, @photo.updated_at ].max)
9
+ expires_in 1.year, public: true
10
+ end
11
+ else
12
+ head :no_content
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,4 @@
1
+ module BeautifulPhotons
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,103 @@
1
+ module BeautifulPhotons
2
+ module PhotoHelper
3
+ def beautiful_photons_image(photo, **options)
4
+ style = "object-fit: cover; object-position: #{photo.focal_x.to_f}% #{photo.focal_y.to_f}%"
5
+
6
+ if (aspect = options.delete(:aspect))
7
+ style = "#{style}; aspect-ratio: #{aspect}"
8
+ end
9
+
10
+ if options[:style]
11
+ style = "#{style}; #{options.delete(:style)}"
12
+ end
13
+
14
+ if photo.mobile_focal_x.present?
15
+ options[:data] = (options[:data] || {}).merge(
16
+ mobile_focal_x: photo.effective_mobile_focal_x.to_f,
17
+ mobile_focal_y: photo.effective_mobile_focal_y.to_f
18
+ )
19
+ end
20
+
21
+ options[:alt] ||= photo.title
22
+ image_tag(url_for(photo.image), **options.merge(style: style))
23
+ end
24
+
25
+ def beautiful_photons_photo(key, **options, &block)
26
+ aspect = options.delete(:aspect)
27
+ mobile_aspect = options.delete(:mobile_aspect)
28
+
29
+ standalone = Standalone.find_or_create_by!(key: key.to_s)
30
+ if aspect && (standalone.aspect != aspect || standalone.mobile_aspect != mobile_aspect)
31
+ standalone.update!(aspect: aspect, mobile_aspect: mobile_aspect)
32
+ end
33
+
34
+ return beautiful_photons_placeholder(**options, &block) unless standalone.photo
35
+
36
+ frame_id = "bp-photo-#{standalone.key}"
37
+ frame_src = beautiful_photons.standalone_photo_path(standalone.key)
38
+
39
+ fallback = beautiful_photons_placeholder(class: "bp-fallback", &block)
40
+
41
+ wrapper_class = [ "bp-photo", options[:class] ].compact.join(" ")
42
+ wrapper_style = [ "overflow: hidden", options[:style] ].compact.join("; ")
43
+
44
+ beautiful_photons_inline_styles +
45
+ content_tag(:div, class: wrapper_class, style: wrapper_style) do
46
+ turbo_frame_tag(frame_id, src: frame_src, loading: "lazy") do
47
+ beautiful_photons_loader
48
+ end +
49
+ content_tag(:div, fallback, class: "bp-fallback-wrap", style: "display: none; position: absolute; inset: 0;")
50
+ end
51
+ end
52
+
53
+ def beautiful_photons_inline_styles
54
+ return "".html_safe if @_bp_styles_rendered
55
+ @_bp_styles_rendered = true
56
+ content_tag(:style) { "@keyframes bp-spin { to { transform: rotate(360deg); } }".html_safe }
57
+ end
58
+
59
+ def beautiful_photons_loader
60
+ content_tag(:div, class: "bp-loader",
61
+ style: "position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: #1a1a1a; z-index: 1; color: currentColor;") do
62
+ content_tag(:svg, viewBox: "0 0 24 24", style: "width: 33%; max-width: 48px; height: auto; animation: bp-spin 1s linear infinite;",
63
+ fill: "none", xmlns: "http://www.w3.org/2000/svg") do
64
+ tag(:circle, cx: "12", cy: "12", r: "10", stroke: "currentColor", "stroke-width": "2", opacity: "0.25") +
65
+ tag(:path, d: "M12 2a10 10 0 0 1 10 10", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round")
66
+ end
67
+ end
68
+ end
69
+
70
+ def beautiful_photons_placeholder(**options, &block)
71
+ css_class = [ "bp-placeholder", options[:class] ].compact.join(" ")
72
+ content_tag(:div, class: css_class,
73
+ style: "display: flex; align-items: center; justify-content: center; background: #1a1a1a; color: currentColor; width: 100%; height: 100%; overflow: hidden;") do
74
+ if block
75
+ capture(&block)
76
+ else
77
+ content_tag(:svg, xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24",
78
+ "stroke-width": "0.75", stroke: "currentColor", class: "bp-placeholder-icon",
79
+ style: "width: 33%; max-width: 120px; height: auto;") do
80
+ tag(:path, "stroke-linecap": "round", "stroke-linejoin": "round",
81
+ d: "m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z")
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ def beautiful_photons_gallery(gallery_name)
88
+ gallery = Gallery.find_by!(name: gallery_name)
89
+ photos = gallery.gallery_photos.joins(:photo).where(photo: { published: true }).order(:position).map(&:photo)
90
+ photos.each { |photo| yield photo }
91
+ end
92
+
93
+ def beautiful_photons_photos(gallery_name, category: nil)
94
+ gallery = Gallery.find_by!(name: gallery_name)
95
+ scope = gallery.gallery_photos.joins(:photo).where(photo: { published: true }).order(:position)
96
+ if category
97
+ scope = scope.joins(photo: :category)
98
+ .where(beautiful_photons_categories: { name: category })
99
+ end
100
+ scope.map(&:photo)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,4 @@
1
+ module BeautifulPhotons
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module BeautifulPhotons
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module BeautifulPhotons
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,7 @@
1
+ module BeautifulPhotons
2
+ class Category < ApplicationRecord
3
+ has_many :photos, dependent: :nullify
4
+
5
+ validates :name, presence: true, uniqueness: true
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ module BeautifulPhotons
2
+ class Gallery < ApplicationRecord
3
+ has_many :gallery_photos, dependent: :destroy
4
+ has_many :photos, through: :gallery_photos
5
+
6
+ validates :name, presence: true, uniqueness: true
7
+ validates :title, presence: true
8
+
9
+ before_validation :generate_name, if: -> { name.blank? && title.present? }
10
+
11
+ private
12
+
13
+ def generate_name
14
+ self.name = title.parameterize(separator: "_")
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,22 @@
1
+ module BeautifulPhotons
2
+ class GalleryPhoto < ApplicationRecord
3
+ belongs_to :gallery
4
+ belongs_to :photo
5
+
6
+ validates :position, presence: true
7
+ validates :photo_id, uniqueness: { scope: %i[gallery_id category] }
8
+
9
+ after_create :publish_photo
10
+ after_destroy :unpublish_photo
11
+
12
+ private
13
+
14
+ def publish_photo
15
+ photo.update_published!
16
+ end
17
+
18
+ def unpublish_photo
19
+ photo.update_published!
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,29 @@
1
+ module BeautifulPhotons
2
+ class Photo < ApplicationRecord
3
+ belongs_to :category, optional: true
4
+
5
+ has_one_attached :image
6
+ has_many :gallery_photos, dependent: :destroy
7
+ has_many :galleries, through: :gallery_photos
8
+ has_many :standalones, dependent: :nullify
9
+
10
+ validates :image, presence: true
11
+ validates :focal_x, :focal_y, numericality: { in: 0..100 }
12
+ validates :mobile_focal_x, :mobile_focal_y, numericality: { in: 0..100 }, allow_nil: true
13
+
14
+ scope :published, -> { where(published: true) }
15
+
16
+ def update_published!
17
+ in_use = gallery_photos.exists? || standalones.exists?
18
+ update_column(:published, in_use) if published? != in_use
19
+ end
20
+
21
+ def effective_mobile_focal_x
22
+ mobile_focal_x || focal_x
23
+ end
24
+
25
+ def effective_mobile_focal_y
26
+ mobile_focal_y || focal_y
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,23 @@
1
+ module BeautifulPhotons
2
+ class Standalone < ApplicationRecord
3
+ belongs_to :photo, optional: true
4
+
5
+ validates :key, presence: true, uniqueness: true
6
+
7
+ before_validation :generate_label, if: -> { label.blank? && key.present? }
8
+ after_save :update_photo_published, if: :saved_change_to_photo_id?
9
+ after_destroy :update_photo_published
10
+
11
+ private
12
+
13
+ def generate_label
14
+ self.label = key.humanize
15
+ end
16
+
17
+ def update_photo_published
18
+ old_id, new_id = saved_change_to_photo_id || [ photo_id, nil ]
19
+ Photo.find_by(id: old_id)&.update_published! if old_id
20
+ Photo.find_by(id: new_id)&.update_published! if new_id
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,28 @@
1
+ <%= ui_page(max_width: :lg) do %>
2
+ <%= ui_page_header(title: "Categories") %>
3
+
4
+ <%= ui_section do %>
5
+ <%= ui_form(action: beautiful_photons.categories_path, method: :post) do %>
6
+ <div style="display: flex; gap: 0.5rem; align-items: center;">
7
+ <%= ui_input(name: "category[name]", placeholder: "New category name") %>
8
+ <%= ui_button(label: "Add", size: :sm) %>
9
+ </div>
10
+ <% end %>
11
+ <% end %>
12
+
13
+ <%= ui_section do %>
14
+ <% if @categories.any? %>
15
+ <% @categories.each do |category| %>
16
+ <div style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0; border-bottom: 1px solid #e5e7eb;">
17
+ <span><%= category.name %></span>
18
+ <%= ui_form(action: beautiful_photons.category_path(category), method: :delete,
19
+ data: { turbo_confirm: "Delete this category?" }) do %>
20
+ <%= ui_button(label: "Delete", type: :submit, variant: :danger, size: :sm) %>
21
+ <% end %>
22
+ </div>
23
+ <% end %>
24
+ <% else %>
25
+ <p style="color: #6b7280;">No categories yet.</p>
26
+ <% end %>
27
+ <% end %>
28
+ <% end %>
@@ -0,0 +1,30 @@
1
+ <%= ui_form_page(title: "Add Photos", back_url: beautiful_photons.gallery_path(@gallery)) %>
2
+
3
+ <%= ui_page(max_width: :lg) do %>
4
+ <% if @available_photos.any? %>
5
+ <%= ui_form(action: beautiful_photons.add_photos_gallery_path(@gallery), method: :post) do %>
6
+ <%= ui_grid(cols: { default: 3, sm: 4, lg: 5 }, gap: :sm) do %>
7
+ <% @available_photos.each do |photo| %>
8
+ <% if photo.image.attached? %>
9
+ <label style="cursor: pointer; position: relative; display: block;">
10
+ <input type="checkbox" name="photo_ids[]" value="<%= photo.id %>"
11
+ style="position: absolute; top: 6px; left: 6px; z-index: 1; width: 18px; height: 18px;">
12
+ <div style="aspect-ratio: 1; background: #1a1a1a; border-radius: 4px; overflow: hidden;">
13
+ <%= image_tag main_app.url_for(photo.image), alt: photo.title,
14
+ loading: "lazy",
15
+ style: "width: 100%; height: 100%; object-fit: cover;" %>
16
+ </div>
17
+ </label>
18
+ <% end %>
19
+ <% end %>
20
+ <% end %>
21
+ <div style="margin-top: 1rem; display: flex; gap: 0.75rem; align-items: center;">
22
+ <label class="text-sm font-medium text-surface-700">Category <span class="text-surface-400">(optional)</span></label>
23
+ <%= ui_input(name: "category", placeholder: "e.g. backsplashes") %>
24
+ <%= ui_button(label: "Add Selected") %>
25
+ </div>
26
+ <% end %>
27
+ <% else %>
28
+ <p>All photos are already in this gallery.</p>
29
+ <% end %>
30
+ <% end %>
@@ -0,0 +1,17 @@
1
+ <%= ui_form_page(title: "Edit Gallery", back_url: beautiful_photons.gallery_path(@gallery)) %>
2
+
3
+ <%= ui_page(max_width: :lg) do %>
4
+ <%= ui_form(action: beautiful_photons.gallery_path(@gallery), method: :patch) do %>
5
+ <%= ui_section do %>
6
+ <label class="block text-sm font-medium text-surface-700 mb-1">Title</label>
7
+ <%= ui_input(name: "gallery[title]", value: @gallery.title) %>
8
+ <% end %>
9
+
10
+ <%= ui_section do %>
11
+ <label class="block text-sm font-medium text-surface-700 mb-1">Description <span class="text-surface-400">(optional)</span></label>
12
+ <%= ui_textarea(name: "gallery[description]", value: @gallery.description) %>
13
+ <% end %>
14
+
15
+ <%= ui_button(label: "Save") %>
16
+ <% end %>
17
+ <% end %>
@@ -0,0 +1,13 @@
1
+ <%= ui_page(max_width: :lg) do %>
2
+ <%= ui_page_header(title: "Galleries") do |header| %>
3
+ <% header.action do %>
4
+ <%= ui_button(label: "New", href: beautiful_photons.new_gallery_path) %>
5
+ <% end %>
6
+ <% end %>
7
+
8
+ <% @galleries.each do |gallery| %>
9
+ <%= ui_card_link(href: beautiful_photons.gallery_path(gallery)) do %>
10
+ <%= gallery.title %>
11
+ <% end %>
12
+ <% end %>
13
+ <% end %>
@@ -0,0 +1,17 @@
1
+ <%= ui_form_page(title: "New Gallery", back_url: beautiful_photons.galleries_path) %>
2
+
3
+ <%= ui_page(max_width: :lg) do %>
4
+ <%= ui_form(action: beautiful_photons.galleries_path, method: :post) do %>
5
+ <%= ui_section do %>
6
+ <label class="block text-sm font-medium text-surface-700 mb-1">Title</label>
7
+ <%= ui_input(name: "gallery[title]", placeholder: "e.g. Backsplashes") %>
8
+ <% end %>
9
+
10
+ <%= ui_section do %>
11
+ <label class="block text-sm font-medium text-surface-700 mb-1">Description <span class="text-surface-400">(optional)</span></label>
12
+ <%= ui_textarea(name: "gallery[description]", placeholder: "Brief description for the public page") %>
13
+ <% end %>
14
+
15
+ <%= ui_button(label: "Create") %>
16
+ <% end %>
17
+ <% end %>
@@ -0,0 +1,84 @@
1
+ <%= ui_show_page(title: @gallery.title, back_url: beautiful_photons.galleries_path) %>
2
+
3
+ <%= ui_page(max_width: :lg) do %>
4
+ <% if @gallery_photos.any? %>
5
+ <div data-controller="reorder bulk-select" data-reorder-url-value="<%= beautiful_photons.reorder_gallery_path(@gallery) %>">
6
+ <%= ui_form(action: beautiful_photons.remove_photos_gallery_path(@gallery), method: :delete,
7
+ data: { bulk_select_target: "form" }) do %>
8
+
9
+ <% if @category_stats.any? %>
10
+ <div style="display: flex; gap: 1rem; margin-bottom: 1rem; font-size: 0.875rem; color: #6b7280;">
11
+ <% total = @category_stats.values.sum %>
12
+ <% @category_stats.sort_by { |_, count| -count }.each do |category, count| %>
13
+ <span><%= ((count.to_f / total) * 100).round %>% <%= category %></span>
14
+ <% end %>
15
+ </div>
16
+ <% end %>
17
+
18
+ <div style="display: flex; gap: 0.5rem; margin-bottom: 1rem; align-items: center;">
19
+ <%= ui_button(label: "Add Photos", href: beautiful_photons.add_photos_page_gallery_path(@gallery)) %>
20
+ <%= ui_button(label: "Edit", href: beautiful_photons.edit_gallery_path(@gallery), variant: :secondary, size: :sm) %>
21
+ <%= ui_form(action: beautiful_photons.gallery_path(@gallery), method: :delete,
22
+ data: { turbo_confirm: "Delete this gallery? This cannot be undone." }) do %>
23
+ <%= ui_button(label: "Delete", type: :submit, variant: :danger, size: :sm) %>
24
+ <% end %>
25
+ <%= ui_button(label: "Remove Selected", type: :button, variant: :danger, size: :sm,
26
+ data: { bulk_select_target: "removeButton", action: "click->bulk-select#showConfirm" }) %>
27
+ </div>
28
+
29
+ <%= ui_grid(cols: { default: 2, sm: 3, lg: 4 }, gap: :md) do %>
30
+ <% @gallery_photos.each do |gp| %>
31
+ <% if gp.photo.image.attached? %>
32
+ <div data-reorder-target="item" data-photo-id="<%= gp.photo.id %>" draggable="true"
33
+ data-action="dragstart->reorder#dragstart dragover->reorder#dragover drop->reorder#drop"
34
+ style="cursor: grab; position: relative;">
35
+ <label style="display: block; cursor: pointer;">
36
+ <input type="checkbox" name="photo_ids[]" value="<%= gp.photo.id %>"
37
+ data-bulk-select-target="checkbox"
38
+ data-action="change->bulk-select#toggle"
39
+ style="position: absolute; top: 6px; left: 6px; z-index: 1; width: 18px; height: 18px;">
40
+ <div style="aspect-ratio: 1; background: #1a1a1a; border-radius: 4px; overflow: hidden;">
41
+ <%= image_tag main_app.url_for(gp.photo.image), alt: gp.photo.title,
42
+ loading: "lazy",
43
+ style: "width: 100%; height: 100%; object-fit: cover;" %>
44
+ </div>
45
+ </label>
46
+ <% if gp.photo.category.present? %>
47
+ <span class="gallery-photo-category" style="display: block; margin-top: 0.25rem; font-size: 0.75rem; color: #6b7280;"><%= gp.photo.category.name %></span>
48
+ <% end %>
49
+ </div>
50
+ <% end %>
51
+ <% end %>
52
+ <% end %>
53
+ <% end %>
54
+
55
+ <%# Confirmation modal %>
56
+ <div data-bulk-select-target="modal"
57
+ data-action="click->bulk-select#closeOnBackdrop"
58
+ class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60">
59
+ <div class="rounded-xl border border-gray-200 bg-white p-6 w-full mx-4 max-w-sm dark:border-zinc-700 dark:bg-zinc-800">
60
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-200 mb-2">Remove Photos</h3>
61
+ <p data-bulk-select-target="modalMessage" class="text-gray-600 dark:text-gray-400 mb-6"></p>
62
+ <div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
63
+ <%= ui_button(label: "Cancel", type: :button, variant: :secondary, size: :sm,
64
+ data: { action: "click->bulk-select#cancelRemove" }) %>
65
+ <%= ui_button(label: "Remove", type: :button, variant: :danger, size: :sm,
66
+ data: { action: "click->bulk-select#confirmRemove" }) %>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ <% else %>
72
+ <div style="display: flex; gap: 0.5rem; margin-bottom: 1rem; align-items: center;">
73
+ <%= ui_button(label: "Add Photos", href: beautiful_photons.add_photos_page_gallery_path(@gallery)) %>
74
+ <%= ui_button(label: "Edit", href: beautiful_photons.edit_gallery_path(@gallery), variant: :secondary, size: :sm) %>
75
+ <%= ui_form(action: beautiful_photons.gallery_path(@gallery), method: :delete,
76
+ data: { turbo_confirm: "Delete this gallery? This cannot be undone." }) do %>
77
+ <%= ui_button(label: "Delete", type: :submit, variant: :danger, size: :sm) %>
78
+ <% end %>
79
+ </div>
80
+ <%= ui_section do %>
81
+ <p>No photos in this gallery yet.</p>
82
+ <% end %>
83
+ <% end %>
84
+ <% end %>