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,35 @@
1
+ <%= ui_form_page(title: "Edit Photo", back_url: beautiful_photons.photo_path(@photo)) %>
2
+
3
+ <%= ui_page(max_width: :lg) do %>
4
+ <%= ui_form(action: beautiful_photons.photo_path(@photo), method: :patch) do %>
5
+ <%= ui_section(title: "Focal Point") do %>
6
+ <div data-controller="focal-point" style="position: relative; cursor: crosshair;">
7
+ <% if @photo.image.attached? %>
8
+ <%= image_tag main_app.url_for(@photo.image),
9
+ alt: @photo.title,
10
+ style: "width: 100%; display: block;",
11
+ data: { focal_point_target: "image", action: "click->focal-point#pick" } %>
12
+ <% end %>
13
+ <div data-focal-point-target="pin"
14
+ style="position: absolute; width: 20px; height: 20px; background: rgba(255,0,0,0.8); border: 2px solid white; border-radius: 50%; transform: translate(-50%, -50%); pointer-events: none; left: <%= @photo.focal_x %>%; top: <%= @photo.focal_y %>%;">
15
+ </div>
16
+ <input type="hidden" name="photo[focal_x]" value="<%= @photo.focal_x %>" data-focal-point-target="focalX">
17
+ <input type="hidden" name="photo[focal_y]" value="<%= @photo.focal_y %>" data-focal-point-target="focalY">
18
+ </div>
19
+ <% end %>
20
+
21
+ <%= ui_section(title: "Details") do %>
22
+ <%= ui_input(name: "photo[title]", value: @photo.title) %>
23
+ <%= ui_textarea(name: "photo[description]", value: @photo.description) %>
24
+ <% end %>
25
+
26
+ <%= ui_section(title: "Mobile Focal Point") do %>
27
+ <%= ui_grid(cols: { default: 2 }, gap: :md) do %>
28
+ <%= ui_input(name: "photo[mobile_focal_x]", type: :number, value: @photo.mobile_focal_x, min: 0, max: 100) %>
29
+ <%= ui_input(name: "photo[mobile_focal_y]", type: :number, value: @photo.mobile_focal_y, min: 0, max: 100) %>
30
+ <% end %>
31
+ <% end %>
32
+
33
+ <%= ui_button(label: "Save") %>
34
+ <% end %>
35
+ <% end %>
@@ -0,0 +1,60 @@
1
+ <%= ui_page(max_width: :lg) do %>
2
+ <%= ui_page_header(title: "Photos") do |header| %>
3
+ <% header.action do %>
4
+ <%= ui_button(label: "Upload", href: beautiful_photons.new_photo_path) %>
5
+ <% end %>
6
+ <% end %>
7
+
8
+ <div data-controller="bulk-select" data-bulk-select-message-value="Delete {count}? This cannot be undone.">
9
+ <%= ui_form(action: beautiful_photons.bulk_destroy_photos_path, method: :delete,
10
+ data: { bulk_select_target: "form" }) do %>
11
+
12
+ <div style="display: flex; gap: 0.5rem; margin-bottom: 1rem; align-items: center;">
13
+ <%= ui_button(label: "Select All", type: :button, variant: :secondary, size: :sm,
14
+ data: { bulk_select_target: "selectAllButton", action: "click->bulk-select#selectAll" }) %>
15
+ <%= ui_button(label: "Unselect All", type: :button, variant: :secondary, size: :sm,
16
+ data: { bulk_select_target: "unselectAllButton", action: "click->bulk-select#unselectAll" }) %>
17
+ <%= ui_button(label: "Delete Selected", type: :button, variant: :danger, size: :sm,
18
+ data: { bulk_select_target: "removeButton", action: "click->bulk-select#showConfirm" }) %>
19
+ </div>
20
+
21
+ <%= ui_grid(cols: { default: 2, sm: 3, lg: 4 }, gap: :md) do %>
22
+ <% @photos.each do |photo| %>
23
+ <% if photo.image.attached? %>
24
+ <div style="position: relative;">
25
+ <input type="checkbox" name="photo_ids[]" value="<%= photo.id %>"
26
+ data-bulk-select-target="checkbox"
27
+ data-action="change->bulk-select#toggle"
28
+ style="position: absolute; top: 6px; left: 6px; z-index: 2; width: 18px; height: 18px;">
29
+ <%= link_to beautiful_photons.photo_path(photo), style: "display: block;" do %>
30
+ <div style="aspect-ratio: 1; background: #1a1a1a; border-radius: 4px; overflow: hidden;">
31
+ <%= image_tag main_app.url_for(photo.image), alt: photo.title,
32
+ loading: "lazy",
33
+ style: "width: 100%; height: 100%; object-fit: cover;",
34
+ onload: "this.style.opacity=1",
35
+ onerror: "this.style.display='none'" %>
36
+ </div>
37
+ <% end %>
38
+ </div>
39
+ <% end %>
40
+ <% end %>
41
+ <% end %>
42
+ <% end %>
43
+
44
+ <%# Confirmation modal %>
45
+ <div data-bulk-select-target="modal"
46
+ data-action="click->bulk-select#closeOnBackdrop"
47
+ class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60">
48
+ <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">
49
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-200 mb-2">Delete Photos</h3>
50
+ <p data-bulk-select-target="modalMessage" class="text-gray-600 dark:text-gray-400 mb-6"></p>
51
+ <div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
52
+ <%= ui_button(label: "Cancel", type: :button, variant: :secondary, size: :sm,
53
+ data: { action: "click->bulk-select#cancelRemove" }) %>
54
+ <%= ui_button(label: "Delete", type: :button, variant: :danger, size: :sm,
55
+ data: { action: "click->bulk-select#confirmRemove" }) %>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ <% end %>
@@ -0,0 +1,10 @@
1
+ <%= ui_form_page(title: "Upload Photos", back_url: beautiful_photons.photos_path) %>
2
+
3
+ <%= ui_page(max_width: :lg) do %>
4
+ <%= ui_form(action: beautiful_photons.bulk_create_photos_path, method: :post, multipart: true) do %>
5
+ <%= ui_file_upload(name: "images[]", accept: "image/*", multiple: true, label: "Choose photos", hint: "Select one or more images. Titles auto-generate from filenames.") %>
6
+ <div style="margin-top: 1rem;">
7
+ <%= ui_button(label: "Upload") %>
8
+ </div>
9
+ <% end %>
10
+ <% end %>
@@ -0,0 +1,66 @@
1
+ <%= ui_show_page(title: @photo.title || "Untitled", back_url: beautiful_photons.photos_path) %>
2
+
3
+ <%= ui_page(max_width: :lg) do %>
4
+ <%= ui_section do %>
5
+ <div style="display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: flex-start;">
6
+ <% if @photo.image.attached? %>
7
+ <div style="width: 300px; flex-shrink: 0;">
8
+ <%= image_tag main_app.url_for(@photo.image), alt: @photo.title,
9
+ style: "width: 100%; border-radius: 4px; object-fit: cover;" %>
10
+ </div>
11
+ <% end %>
12
+ <div style="flex: 1; min-width: 200px;">
13
+ <% if @photo.description.present? %>
14
+ <p style="color: #6b7280; margin-bottom: 1rem;"><%= @photo.description %></p>
15
+ <% end %>
16
+ <div style="margin-bottom: 0.75rem;">
17
+ <%= ui_form(action: beautiful_photons.photo_path(@photo), method: :patch) do %>
18
+ <label class="block text-sm font-medium text-surface-700 mb-1">Category</label>
19
+ <%= ui_select(name: "photo[category_id]",
20
+ options: BeautifulPhotons::Category.order(:name).map { |c| [ c.name, c.id ] },
21
+ selected: @photo.category_id,
22
+ include_blank: "None") %>
23
+ <%= ui_button(label: "Save", size: :sm) %>
24
+ <% end %>
25
+ </div>
26
+ <div style="margin-bottom: 0.5rem; display: flex; gap: 0.5rem; align-items: center;">
27
+ <%= ui_button(label: "Edit", href: beautiful_photons.edit_photo_path(@photo), variant: :secondary, size: :sm) %>
28
+ <%= ui_form(action: beautiful_photons.photo_path(@photo), method: :delete, data: { turbo_confirm: "Delete this photo? This cannot be undone." }) do %>
29
+ <%= ui_button(label: "Delete", type: :submit, variant: :danger, size: :sm) %>
30
+ <% end %>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ <% end %>
35
+
36
+ <%= ui_section(title: "Standalones") do %>
37
+ <% standalones = @photo.standalones %>
38
+ <% if standalones.any? %>
39
+ <% standalones.each do |standalone| %>
40
+ <div>
41
+ <%= link_to standalone.label, beautiful_photons.standalone_path(standalone) %>
42
+ <span style="color: #6b7280;">(<%= standalone.key %>)</span>
43
+ </div>
44
+ <% end %>
45
+ <% else %>
46
+ <p>Not used in any standalone placements</p>
47
+ <% end %>
48
+ <% end %>
49
+
50
+ <%= ui_section(title: "Galleries") do %>
51
+ <% gallery_photos = @photo.gallery_photos.includes(:gallery) %>
52
+ <% if gallery_photos.any? %>
53
+ <% gallery_photos.each do |gp| %>
54
+ <div>
55
+ <%= link_to gp.gallery.title, beautiful_photons.gallery_path(gp.gallery) %>
56
+ — position <%= gp.position %>
57
+ <% if gp.category.present? %>
58
+ (<%= gp.category %>)
59
+ <% end %>
60
+ </div>
61
+ <% end %>
62
+ <% else %>
63
+ <p>Not in any galleries</p>
64
+ <% end %>
65
+ <% end %>
66
+ <% end %>
@@ -0,0 +1,39 @@
1
+ <%= ui_form_page(title: "Change Photo", back_url: beautiful_photons.standalone_path(@standalone)) %>
2
+
3
+ <%= ui_page(max_width: :lg) do %>
4
+ <% if @standalone.photo&.image&.attached? %>
5
+ <%= ui_section(title: "Current") do %>
6
+ <div style="display: flex; align-items: center; gap: 1rem;">
7
+ <%= image_tag main_app.url_for(@standalone.photo.image), alt: @standalone.photo.title,
8
+ style: "width: 80px; height: 80px; object-fit: cover; border-radius: 4px;" %>
9
+ <div>
10
+ <strong><%= @standalone.photo.title || "Untitled" %></strong>
11
+ <%= ui_form(action: beautiful_photons.standalone_path(@standalone), method: :patch) do %>
12
+ <input type="hidden" name="standalone[photo_id]" value="">
13
+ <%= ui_button(label: "Remove", variant: :danger, size: :sm) %>
14
+ <% end %>
15
+ </div>
16
+ </div>
17
+ <% end %>
18
+ <% end %>
19
+
20
+ <%= ui_section(title: "Choose Photo") do %>
21
+ <% if @photos.any? %>
22
+ <%= ui_grid(cols: { default: 3, sm: 4, lg: 5 }, gap: :sm) do %>
23
+ <% @photos.each do |photo| %>
24
+ <% if photo.image.attached? %>
25
+ <%= ui_form(action: beautiful_photons.standalone_path(@standalone), method: :patch) do %>
26
+ <input type="hidden" name="standalone[photo_id]" value="<%= photo.id %>">
27
+ <button type="submit" style="cursor: pointer; border: none; background: none; padding: 0; width: 100%;">
28
+ <% border_color = @standalone.photo_id == photo.id ? "#D4636D" : "transparent" %>
29
+ <%= image_tag main_app.url_for(photo.image), alt: photo.title, style: "width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; border: 2px solid #{border_color};" %>
30
+ </button>
31
+ <% end %>
32
+ <% end %>
33
+ <% end %>
34
+ <% end %>
35
+ <% else %>
36
+ <p>No photos uploaded yet.</p>
37
+ <% end %>
38
+ <% end %>
39
+ <% end %>
@@ -0,0 +1,38 @@
1
+ <% desktop_aspect = @standalone.aspect.presence || "4:3" %>
2
+ <%= ui_form_page(title: "Desktop Crop (#{desktop_aspect})", back_url: beautiful_photons.standalone_path(@standalone)) %>
3
+
4
+ <%= ui_page(max_width: :lg) do %>
5
+ <%= ui_form(action: beautiful_photons.standalone_path(@standalone), method: :patch) do %>
6
+ <input type="hidden" name="standalone[photo_id]" value="<%= @standalone.photo_id %>">
7
+
8
+ <div data-controller="crop-editor" data-crop-editor-aspect-value="<%= desktop_aspect %>">
9
+ <div style="position: relative; overflow: hidden; user-select: none; border-radius: 4px;">
10
+ <%= image_tag main_app.url_for(@standalone.photo.image), alt: @standalone.photo.title,
11
+ style: "width: 100%; display: block;",
12
+ data: { crop_editor_target: "image" } %>
13
+ <div data-crop-editor-target="viewport"
14
+ data-action="mousedown->crop-editor#startDrag touchstart->crop-editor#startDrag"
15
+ style="position: absolute; border: 2px solid rgba(255,255,255,0.8); background: rgba(255,255,255,0.1); box-shadow: 0 0 0 9999px rgba(0,0,0,0.5); cursor: grab; display: none;">
16
+ </div>
17
+ </div>
18
+
19
+ <input type="hidden" name="standalone[crop_x]" value="<%= @standalone.crop_x %>" data-crop-editor-target="cropX">
20
+ <input type="hidden" name="standalone[crop_y]" value="<%= @standalone.crop_y %>" data-crop-editor-target="cropY">
21
+ <input type="hidden" name="standalone[crop_zoom]" value="<%= @standalone.crop_zoom %>" data-crop-editor-target="cropZoom">
22
+
23
+ <div style="margin-top: 0.75rem; display: flex; align-items: center; gap: 0.75rem;">
24
+ <label style="color: #6b7280; font-size: 0.875rem; white-space: nowrap;">Zoom</label>
25
+ <input type="range" min="1" max="3" step="0.05" value="<%= @standalone.crop_zoom %>"
26
+ data-crop-editor-target="zoomSlider"
27
+ data-action="input->crop-editor#sliderZoom"
28
+ style="flex: 1;">
29
+ </div>
30
+ </div>
31
+
32
+ <p style="color: #6b7280; font-size: 0.75rem; margin-top: 0.5rem;">Drag to position. Use slider to zoom.</p>
33
+
34
+ <div style="margin-top: 1rem;">
35
+ <%= ui_button(label: "Save") %>
36
+ </div>
37
+ <% end %>
38
+ <% end %>
@@ -0,0 +1,37 @@
1
+ <%= ui_form_page(title: "Mobile Crop (#{@standalone.mobile_aspect})", back_url: beautiful_photons.standalone_path(@standalone)) %>
2
+
3
+ <%= ui_page(max_width: :lg) do %>
4
+ <%= ui_form(action: beautiful_photons.standalone_path(@standalone), method: :patch) do %>
5
+ <input type="hidden" name="standalone[photo_id]" value="<%= @standalone.photo_id %>">
6
+
7
+ <div data-controller="crop-editor" data-crop-editor-aspect-value="<%= @standalone.mobile_aspect %>">
8
+ <div style="position: relative; overflow: hidden; user-select: none; border-radius: 4px;">
9
+ <%= image_tag main_app.url_for(@standalone.photo.image), alt: @standalone.photo.title,
10
+ style: "width: 100%; display: block;",
11
+ data: { crop_editor_target: "image" } %>
12
+ <div data-crop-editor-target="viewport"
13
+ data-action="mousedown->crop-editor#startDrag touchstart->crop-editor#startDrag"
14
+ style="position: absolute; border: 2px solid rgba(255,255,255,0.8); background: rgba(255,255,255,0.1); box-shadow: 0 0 0 9999px rgba(0,0,0,0.5); cursor: grab; display: none;">
15
+ </div>
16
+ </div>
17
+
18
+ <input type="hidden" name="standalone[mobile_crop_x]" value="<%= @standalone.mobile_crop_x %>" data-crop-editor-target="cropX">
19
+ <input type="hidden" name="standalone[mobile_crop_y]" value="<%= @standalone.mobile_crop_y %>" data-crop-editor-target="cropY">
20
+ <input type="hidden" name="standalone[mobile_crop_zoom]" value="<%= @standalone.mobile_crop_zoom %>" data-crop-editor-target="cropZoom">
21
+
22
+ <div style="margin-top: 0.75rem; display: flex; align-items: center; gap: 0.75rem;">
23
+ <label style="color: #6b7280; font-size: 0.875rem; white-space: nowrap;">Zoom</label>
24
+ <input type="range" min="1" max="3" step="0.05" value="<%= @standalone.mobile_crop_zoom %>"
25
+ data-crop-editor-target="zoomSlider"
26
+ data-action="input->crop-editor#sliderZoom"
27
+ style="flex: 1;">
28
+ </div>
29
+ </div>
30
+
31
+ <p style="color: #6b7280; font-size: 0.75rem; margin-top: 0.5rem;">Drag to position. Use slider to zoom.</p>
32
+
33
+ <div style="margin-top: 1rem;">
34
+ <%= ui_button(label: "Save") %>
35
+ </div>
36
+ <% end %>
37
+ <% end %>
@@ -0,0 +1,27 @@
1
+ <%= ui_page(max_width: :lg) do %>
2
+ <%= ui_page_header(title: "Standalones") %>
3
+
4
+ <% @standalones.each do |standalone| %>
5
+ <%= ui_card_link(href: beautiful_photons.standalone_path(standalone)) do %>
6
+ <div style="display: flex; align-items: center; gap: 0.75rem;">
7
+ <% if standalone.photo&.image&.attached? %>
8
+ <%= image_tag main_app.url_for(standalone.photo.image), alt: standalone.label, style: "width: 48px; height: 48px; object-fit: cover; border-radius: 4px;" %>
9
+ <% else %>
10
+ <div style="width: 48px; height: 48px; background: #1C1C1C; border-radius: 4px; display: flex; align-items: center; justify-content: center;">
11
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="0.75" stroke="#666" style="width: 24px; height: 24px;">
12
+ <path stroke-linecap="round" stroke-linejoin="round" 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" />
13
+ </svg>
14
+ </div>
15
+ <% end %>
16
+ <div>
17
+ <strong><%= standalone.label %></strong>
18
+ <div style="color: #6b7280; font-size: 0.875rem;"><%= standalone.key %></div>
19
+ </div>
20
+ </div>
21
+ <% end %>
22
+ <% end %>
23
+
24
+ <% if @standalones.empty? %>
25
+ <p>No standalone placements registered yet. Add <code>beautiful_photons_photo("key")</code> to your views to register them.</p>
26
+ <% end %>
27
+ <% end %>
@@ -0,0 +1,52 @@
1
+ <%= ui_show_page(title: @standalone.label, back_url: beautiful_photons.standalones_path) %>
2
+
3
+ <%= ui_page(max_width: :lg) do %>
4
+ <% if @standalone.photo&.image&.attached? %>
5
+ <%= ui_section do %>
6
+ <div style="display: flex; gap: 1.5rem; align-items: flex-start;">
7
+ <%= image_tag main_app.url_for(@standalone.photo.image), alt: @standalone.photo.title,
8
+ style: "width: 200px; border-radius: 4px; object-fit: cover; aspect-ratio: 4/3;" %>
9
+ <div>
10
+ <strong><%= @standalone.photo.title || "Untitled" %></strong>
11
+ <div style="color: #6b7280; font-size: 0.875rem; margin-top: 0.25rem;"><%= @standalone.key %></div>
12
+ <% if @standalone.aspect.present? %>
13
+ <div style="color: #6b7280; font-size: 0.875rem; margin-top: 0.25rem;">Desktop: <%= @standalone.aspect %> &middot; Mobile: <%= @standalone.mobile_aspect || "not set" %></div>
14
+ <% end %>
15
+ <div style="margin-top: 1rem;">
16
+ <%= ui_button(label: "Change Photo", href: beautiful_photons.change_photo_standalone_path(@standalone), variant: :secondary, size: :sm) %>
17
+ </div>
18
+ </div>
19
+ </div>
20
+ <% end %>
21
+
22
+ <%= ui_section(title: "Desktop Crop") do %>
23
+ <div style="max-width: 400px; overflow: hidden; border-radius: 4px;">
24
+ <% desktop_aspect = @standalone.aspect.presence || "4:3" %>
25
+ <%= image_tag main_app.url_for(@standalone.photo.image), alt: "Desktop preview",
26
+ style: "width: 100%; aspect-ratio: #{desktop_aspect.tr(':', '/')}; object-fit: cover; object-position: #{@standalone.crop_x}% #{@standalone.crop_y}%; #{"transform: scale(#{@standalone.crop_zoom});" if @standalone.crop_zoom.to_f != 1.0}" %>
27
+ </div>
28
+ <div style="margin-top: 0.75rem;">
29
+ <%= ui_button(label: "Edit", href: beautiful_photons.edit_crop_standalone_path(@standalone), variant: :secondary, size: :sm) %>
30
+ </div>
31
+ <% end %>
32
+
33
+ <% if @standalone.mobile_aspect.present? %>
34
+ <%= ui_section(title: "Mobile Crop") do %>
35
+ <div style="max-width: 200px; overflow: hidden; border-radius: 4px;">
36
+ <%= image_tag main_app.url_for(@standalone.photo.image), alt: "Mobile preview",
37
+ style: "width: 100%; aspect-ratio: #{@standalone.mobile_aspect.tr(':', '/')}; object-fit: cover; object-position: #{@standalone.mobile_crop_x}% #{@standalone.mobile_crop_y}%; #{"transform: scale(#{@standalone.mobile_crop_zoom});" if @standalone.mobile_crop_zoom.to_f != 1.0}" %>
38
+ </div>
39
+ <div style="margin-top: 0.75rem;">
40
+ <%= ui_button(label: "Edit", href: beautiful_photons.edit_mobile_crop_standalone_path(@standalone), variant: :secondary, size: :sm) %>
41
+ </div>
42
+ <% end %>
43
+ <% end %>
44
+ <% else %>
45
+ <%= ui_section do %>
46
+ <p>No photo assigned.</p>
47
+ <div style="margin-top: 0.75rem;">
48
+ <%= ui_button(label: "Assign Photo", href: beautiful_photons.change_photo_standalone_path(@standalone)) %>
49
+ </div>
50
+ <% end %>
51
+ <% end %>
52
+ <% end %>
@@ -0,0 +1,27 @@
1
+ <%= turbo_frame_tag "bp-photo-#{@standalone.key}" do %>
2
+ <% crop_style = "object-position: #{@standalone.crop_x.to_f}% #{@standalone.crop_y.to_f}%" %>
3
+ <% crop_style += "; transform: scale(#{@standalone.crop_zoom.to_f})" if @standalone.crop_zoom.to_f != 1.0 %>
4
+
5
+ <% if @standalone.mobile_crop_x != @standalone.crop_x ||
6
+ @standalone.mobile_crop_y != @standalone.crop_y ||
7
+ @standalone.mobile_crop_zoom != @standalone.crop_zoom %>
8
+ <style>
9
+ @media (max-width: 768px) {
10
+ #bp-img-<%= @standalone.id %> {
11
+ object-position: <%= @standalone.mobile_crop_x.to_f %>% <%= @standalone.mobile_crop_y.to_f %>% !important;
12
+ <% if @standalone.mobile_crop_zoom.to_f != 1.0 %>
13
+ transform: scale(<%= @standalone.mobile_crop_zoom.to_f %>) !important;
14
+ <% end %>
15
+ }
16
+ }
17
+ </style>
18
+ <% end %>
19
+
20
+ <%= image_tag main_app.url_for(@photo.image),
21
+ id: "bp-img-#{@standalone.id}",
22
+ alt: @photo.title,
23
+ loading: "lazy",
24
+ style: "width: 100%; height: 100%; object-fit: cover; #{crop_style}; opacity: 0; transition: opacity 0.3s;",
25
+ onload: "this.style.opacity=1",
26
+ onerror: "this.style.display='none';var fw=this.closest('.bp-photo').querySelector('.bp-fallback-wrap');if(fw)fw.style.display='flex'" %>
27
+ <% end %>
@@ -0,0 +1,37 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Photos Admin</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+ <% if lookup_context.exists?("head", ["application"], true) %>
8
+ <%= render "head" %>
9
+ <% end %>
10
+ </head>
11
+ <body>
12
+ <nav style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1rem; border-bottom: 1px solid #e5e7eb; background: #fff;">
13
+ <% if defined?(main_app) && main_app.respond_to?(:madmin_root_path) %>
14
+ <%= link_to "Admin", main_app.madmin_root_path, style: "color: #6b7280; text-decoration: none;" %>
15
+ <span style="color: #d1d5db;">|</span>
16
+ <% end %>
17
+ <%= link_to "Photos", beautiful_photons.photos_path, class: "text-sm font-medium no-underline #{controller_name == 'photos' ? 'text-accent-600 border-b-2 border-accent-600 pb-1' : 'text-surface-500 hover:text-accent-600'}" %>
18
+ <%= link_to "Galleries", beautiful_photons.galleries_path, class: "text-sm font-medium no-underline #{controller_name == 'galleries' ? 'text-accent-600 border-b-2 border-accent-600 pb-1' : 'text-surface-500 hover:text-accent-600'}" %>
19
+ <%= link_to "Standalones", beautiful_photons.standalones_path, class: "text-sm font-medium no-underline #{controller_name == 'standalones' ? 'text-accent-600 border-b-2 border-accent-600 pb-1' : 'text-surface-500 hover:text-accent-600'}" %>
20
+ <%= link_to "Categories", beautiful_photons.categories_path, class: "text-sm font-medium no-underline #{controller_name == 'categories' ? 'text-accent-600 border-b-2 border-accent-600 pb-1' : 'text-surface-500 hover:text-accent-600'}" %>
21
+ </nav>
22
+
23
+ <main style="padding: 1rem;">
24
+ <% if flash[:notice] %>
25
+ <div style="background: #065f46; color: #d1fae5; padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.875rem;">
26
+ <%= flash[:notice] %>
27
+ </div>
28
+ <% end %>
29
+ <% if flash[:alert] %>
30
+ <div style="background: #991b1b; color: #fecaca; padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.875rem;">
31
+ <%= flash[:alert] %>
32
+ </div>
33
+ <% end %>
34
+ <%= yield %>
35
+ </main>
36
+ </body>
37
+ </html>
@@ -0,0 +1,5 @@
1
+ pin "beautiful_photons/index", to: "beautiful_photons/index.js"
2
+ pin "beautiful_photons/focal_point_controller", to: "beautiful_photons/focal_point_controller.js"
3
+ pin "beautiful_photons/reorder_controller", to: "beautiful_photons/reorder_controller.js"
4
+ pin "beautiful_photons/crop_editor_controller", to: "beautiful_photons/crop_editor_controller.js"
5
+ pin "beautiful_photons/bulk_select_controller", to: "beautiful_photons/bulk_select_controller.js"
data/config/routes.rb ADDED
@@ -0,0 +1,36 @@
1
+ BeautifulPhotons::Engine.routes.draw do
2
+ scope module: :admin do
3
+ resources :photos, only: [ :index, :show, :new, :create, :edit, :update, :destroy ] do
4
+ post :bulk_create, on: :collection
5
+ delete :bulk_destroy, on: :collection
6
+ end
7
+ resources :standalones, only: [ :index, :show, :update ] do
8
+ member do
9
+ get :edit_crop
10
+ get :edit_mobile_crop
11
+ get :change_photo
12
+ end
13
+ end
14
+ resources :categories, only: [ :index, :create, :destroy ]
15
+ resources :galleries, only: [ :index, :show, :new, :create, :edit, :update, :destroy ] do
16
+ member do
17
+ patch :reorder
18
+ get :add_photos_page
19
+ post :add_photos
20
+ delete :remove_photos
21
+ end
22
+ end
23
+ end
24
+
25
+ # Public turbo frame endpoint for standalone photos
26
+ get "standalone_photo/:key", to: "standalone_photos#show", as: :standalone_photo
27
+
28
+ namespace :api do
29
+ namespace :v1 do
30
+ resources :photos
31
+ resources :galleries do
32
+ resources :photos, controller: "gallery_photos", only: [ :index, :create, :update, :destroy ], as: :photos
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,15 @@
1
+ class CreateBeautifulPhotonsPhotos < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :beautiful_photons_photos do |t|
4
+ t.string :title
5
+ t.text :description
6
+ t.decimal :focal_x, default: 50.0, null: false
7
+ t.decimal :focal_y, default: 50.0, null: false
8
+ t.decimal :mobile_focal_x
9
+ t.decimal :mobile_focal_y
10
+ t.boolean :published, default: false, null: false
11
+
12
+ t.timestamps
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ class CreateBeautifulPhotonsGalleries < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :beautiful_photons_galleries do |t|
4
+ t.string :name, null: false
5
+ t.string :title, null: false
6
+ t.text :description
7
+
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :beautiful_photons_galleries, :name, unique: true
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ class CreateBeautifulPhotonsGalleryPhotos < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :beautiful_photons_gallery_photos do |t|
4
+ t.references :gallery, null: false, foreign_key: { to_table: :beautiful_photons_galleries }
5
+ t.references :photo, null: false, foreign_key: { to_table: :beautiful_photons_photos }
6
+ t.integer :position, null: false
7
+ t.string :category
8
+
9
+ t.timestamps
10
+ end
11
+
12
+ add_index :beautiful_photons_gallery_photos, %i[gallery_id photo_id category],
13
+ unique: true, name: "idx_bp_gallery_photos_uniqueness"
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ class CreateBeautifulPhotonsStandalones < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :beautiful_photons_standalones do |t|
4
+ t.string :key, null: false
5
+ t.string :label
6
+ t.references :photo, foreign_key: { to_table: :beautiful_photons_photos }
7
+
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :beautiful_photons_standalones, :key, unique: true
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ class AddCropToBeautifulPhotonsStandalones < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :beautiful_photons_standalones, :aspect, :string
4
+ add_column :beautiful_photons_standalones, :mobile_aspect, :string
5
+ add_column :beautiful_photons_standalones, :crop_x, :decimal, default: 50.0, null: false
6
+ add_column :beautiful_photons_standalones, :crop_y, :decimal, default: 50.0, null: false
7
+ add_column :beautiful_photons_standalones, :crop_zoom, :decimal, default: 1.0, null: false
8
+ add_column :beautiful_photons_standalones, :mobile_crop_x, :decimal, default: 50.0, null: false
9
+ add_column :beautiful_photons_standalones, :mobile_crop_y, :decimal, default: 50.0, null: false
10
+ add_column :beautiful_photons_standalones, :mobile_crop_zoom, :decimal, default: 1.0, null: false
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ class CreateBeautifulPhotonsCategories < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :beautiful_photons_categories do |t|
4
+ t.string :name, null: false
5
+
6
+ t.timestamps
7
+ end
8
+
9
+ add_index :beautiful_photons_categories, :name, unique: true
10
+ end
11
+ end
@@ -0,0 +1,6 @@
1
+ class AddCategoryToBeautifulPhotonsPhotos < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_reference :beautiful_photons_photos, :category, null: true,
4
+ foreign_key: { to_table: :beautiful_photons_categories }
5
+ end
6
+ end
@@ -0,0 +1,30 @@
1
+ module BeautifulPhotons
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace BeautifulPhotons
4
+
5
+ initializer "beautiful_photons.migrations" do |app|
6
+ config.paths["db/migrate"].expanded.each do |expanded_path|
7
+ app.config.paths["db/migrate"] << expanded_path
8
+ end
9
+ end
10
+
11
+ initializer "beautiful_photons.importmap", before: "importmap" do |app|
12
+ if app.config.respond_to?(:importmap)
13
+ app.config.importmap.paths << root.join("config/importmap.rb")
14
+ end
15
+ end
16
+
17
+ initializer "beautiful_photons.url_helpers" do
18
+ ActiveSupport.on_load(:action_controller) do
19
+ helper Rails.application.routes.url_helpers
20
+ end
21
+ end
22
+
23
+ initializer "beautiful_photons.view_helpers" do
24
+ ActiveSupport.on_load(:action_view) do
25
+ require_relative "../../app/helpers/beautiful_photons/photo_helper"
26
+ include BeautifulPhotons::PhotoHelper
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,3 @@
1
+ module BeautifulPhotons
2
+ VERSION = "0.1.0"
3
+ end