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,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 %> · 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>
|
data/config/importmap.rb
ADDED
|
@@ -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,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
|