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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +28 -0
- data/Rakefile +11 -0
- data/app/assets/javascripts/beautiful_photons/bulk_select_controller.js +76 -0
- data/app/assets/javascripts/beautiful_photons/crop_editor_controller.js +108 -0
- data/app/assets/javascripts/beautiful_photons/focal_point_controller.js +16 -0
- data/app/assets/javascripts/beautiful_photons/index.js +13 -0
- data/app/assets/javascripts/beautiful_photons/reorder_controller.js +45 -0
- data/app/assets/stylesheets/beautiful_photons/application.css +15 -0
- data/app/controllers/beautiful_photons/admin/base_controller.rb +10 -0
- data/app/controllers/beautiful_photons/admin/categories_controller.rb +37 -0
- data/app/controllers/beautiful_photons/admin/galleries_controller.rb +100 -0
- data/app/controllers/beautiful_photons/admin/photos_controller.rb +75 -0
- data/app/controllers/beautiful_photons/admin/standalones_controller.rb +54 -0
- data/app/controllers/beautiful_photons/api/base_controller.rb +15 -0
- data/app/controllers/beautiful_photons/api/v1/galleries_controller.rb +60 -0
- data/app/controllers/beautiful_photons/api/v1/gallery_photos_controller.rb +60 -0
- data/app/controllers/beautiful_photons/api/v1/photos_controller.rb +65 -0
- data/app/controllers/beautiful_photons/application_controller.rb +4 -0
- data/app/controllers/beautiful_photons/standalone_photos_controller.rb +16 -0
- data/app/helpers/beautiful_photons/application_helper.rb +4 -0
- data/app/helpers/beautiful_photons/photo_helper.rb +103 -0
- data/app/jobs/beautiful_photons/application_job.rb +4 -0
- data/app/mailers/beautiful_photons/application_mailer.rb +6 -0
- data/app/models/beautiful_photons/application_record.rb +5 -0
- data/app/models/beautiful_photons/category.rb +7 -0
- data/app/models/beautiful_photons/gallery.rb +17 -0
- data/app/models/beautiful_photons/gallery_photo.rb +22 -0
- data/app/models/beautiful_photons/photo.rb +29 -0
- data/app/models/beautiful_photons/standalone.rb +23 -0
- data/app/views/beautiful_photons/admin/categories/index.html.erb +28 -0
- data/app/views/beautiful_photons/admin/galleries/add_photos_page.html.erb +30 -0
- data/app/views/beautiful_photons/admin/galleries/edit.html.erb +17 -0
- data/app/views/beautiful_photons/admin/galleries/index.html.erb +13 -0
- data/app/views/beautiful_photons/admin/galleries/new.html.erb +17 -0
- data/app/views/beautiful_photons/admin/galleries/show.html.erb +84 -0
- data/app/views/beautiful_photons/admin/photos/edit.html.erb +35 -0
- data/app/views/beautiful_photons/admin/photos/index.html.erb +60 -0
- data/app/views/beautiful_photons/admin/photos/new.html.erb +10 -0
- data/app/views/beautiful_photons/admin/photos/show.html.erb +66 -0
- data/app/views/beautiful_photons/admin/standalones/change_photo.html.erb +39 -0
- data/app/views/beautiful_photons/admin/standalones/edit_crop.html.erb +38 -0
- data/app/views/beautiful_photons/admin/standalones/edit_mobile_crop.html.erb +37 -0
- data/app/views/beautiful_photons/admin/standalones/index.html.erb +27 -0
- data/app/views/beautiful_photons/admin/standalones/show.html.erb +52 -0
- data/app/views/beautiful_photons/standalone_photos/show.html.erb +27 -0
- data/app/views/layouts/beautiful_photons/admin.html.erb +37 -0
- data/config/importmap.rb +5 -0
- data/config/routes.rb +36 -0
- data/db/migrate/20260318000001_create_beautiful_photons_photos.rb +15 -0
- data/db/migrate/20260318000002_create_beautiful_photons_galleries.rb +13 -0
- data/db/migrate/20260318000003_create_beautiful_photons_gallery_photos.rb +15 -0
- data/db/migrate/20260318000004_create_beautiful_photons_standalones.rb +13 -0
- data/db/migrate/20260318000005_add_crop_to_beautiful_photons_standalones.rb +12 -0
- data/db/migrate/20260318185017_create_beautiful_photons_categories.rb +11 -0
- data/db/migrate/20260318194125_add_category_to_beautiful_photons_photos.rb +6 -0
- data/lib/beautiful_photons/engine.rb +30 -0
- data/lib/beautiful_photons/version.rb +3 -0
- data/lib/beautiful_photons.rb +31 -0
- data/lib/generators/beautiful_photons/install_generator.rb +24 -0
- data/lib/tasks/beautiful_photons_tasks.rake +4 -0
- 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,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,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,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 %>
|