mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-02 20:15:22 +02:00
Feature/profile image uploads (#687)
* Introduce ActiveStorage * Add active storage related service gems * Update storage.yml * Install image processing gem - sudo apt-get install libvips (required dependency) * Set default active storage service * Add profile image to user model * Amend form to allow profile images to be saved, introduce stimulus controller. * Purge image when form is blank * Update markup/stimulus controller * Add test for profile image uplaods * Add profile image validation * Use rails guide gem versions * Use correct ERB syntax and make all storage options configurable * Ensure form submits when user clears profile image * Add profile image thumbnail method * Extract profile image to a partial * Updates env.example and storage.yml * Fix bug with double form save * Add profile image to the sidenav * Update production config * Fix ERB formatting * normalize en.yml * Handle non-square images * Use pre-processing on thumbnail variant * Resovle gemfile.lock issues * Rubocop style changes --------- Signed-off-by: Christian <47796704+crobbo@users.noreply.github.com> Co-authored-by: Christian Robinson <christian@robbo.dev>
This commit is contained in:
parent
19ee773d9b
commit
dc024d63b0
17 changed files with 349 additions and 54 deletions
|
@ -5,6 +5,10 @@ class Settings::ProfilesController < ApplicationController
|
|||
def update
|
||||
user_params_with_family = user_params
|
||||
|
||||
if params[:user][:delete_profile_image] == "true"
|
||||
Current.user.profile_image.purge
|
||||
end
|
||||
|
||||
if Current.family && user_params_with_family[:family_attributes]
|
||||
family_attributes = user_params_with_family[:family_attributes].merge({ id: Current.family.id })
|
||||
user_params_with_family[:family_attributes] = family_attributes
|
||||
|
@ -13,7 +17,7 @@ class Settings::ProfilesController < ApplicationController
|
|||
if Current.user.update(user_params_with_family)
|
||||
redirect_to settings_profile_path, notice: t(".success")
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
redirect_to settings_profile_path, alert: t(".file_size_error")
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -29,7 +33,7 @@ class Settings::ProfilesController < ApplicationController
|
|||
private
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:first_name, :last_name,
|
||||
params.require(:user).permit(:first_name, :last_name, :profile_image,
|
||||
family_attributes: [ :name, :id ])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["imagePreview", "fileField", "deleteField", "clearBtn", "template"]
|
||||
|
||||
preview(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.imagePreviewTarget.innerHTML = `<img src="${e.target.result}" alt="Preview" class="w-24 h-24 rounded-full object-cover" />`;
|
||||
this.templateTarget.classList.add("hidden");
|
||||
this.clearBtnTarget.classList.remove("hidden");
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.deleteFieldTarget.value = true;
|
||||
this.fileFieldTarget.value = null;
|
||||
this.templateTarget.classList.remove("hidden");
|
||||
this.imagePreviewTarget.innerHTML = this.templateTarget.innerHTML;
|
||||
this.clearBtnTarget.classList.add("hidden");
|
||||
this.element.submit();
|
||||
}
|
||||
}
|
|
@ -11,6 +11,12 @@ class User < ApplicationRecord
|
|||
|
||||
enum :role, { member: "member", admin: "admin" }, validate: true
|
||||
|
||||
has_one_attached :profile_image do |attachable|
|
||||
attachable.variant :thumbnail, resize_to_limit: [ 150, 150 ], preprocessed: true
|
||||
end
|
||||
|
||||
validate :profile_image_size
|
||||
|
||||
generates_token_for :password_reset, expires_in: 15.minutes do
|
||||
password_salt&.last(10)
|
||||
end
|
||||
|
@ -74,4 +80,10 @@ class User < ApplicationRecord
|
|||
def deactivated_email
|
||||
email.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@")
|
||||
end
|
||||
|
||||
def profile_image_size
|
||||
if profile_image.attached? && profile_image.byte_size > 5.megabytes
|
||||
errors.add(:profile_image, "is too large. Maximum size is 5 MB.")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,11 +4,24 @@
|
|||
<% end %>
|
||||
<div id="user-menu" data-controller="menu">
|
||||
<button data-menu-target="button">
|
||||
<div class="text-white w-9 h-9 bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= Current.user.initial %></div>
|
||||
<% profile_image_attached = Current.user.profile_image.attached? %>
|
||||
<% if profile_image_attached %>
|
||||
<div class="text-white w-9 h-9">
|
||||
<%= render "shared/user_profile_image", user: Current.user %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-white w-9 h-9 bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= Current.user.initial %></div>
|
||||
<% end %>
|
||||
</button>
|
||||
<div data-menu-target="content" class="hidden absolute w-[240px] z-10 top-10 left-[255px] top-[72px] bg-white rounded-sm shadow-xs border border-alpha-black-25">
|
||||
<div class="p-3 flex items-center gap-3">
|
||||
<div class="text-white shrink-0 w-9 h-9 bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= Current.user.initial %></div>
|
||||
<% if profile_image_attached %>
|
||||
<div class="text-white shrink-0 w-9 h-9">
|
||||
<%= render "shared/user_profile_image", user: Current.user %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-white shrink-0 w-9 h-9 bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= Current.user.initial %></div>
|
||||
<% end %>
|
||||
<div>
|
||||
<span class="text-gray-900 font-medium text-sm"><%= Current.user.display_name %></span>
|
||||
<% if Current.user.display_name != Current.user.email %>
|
||||
|
|
|
@ -5,26 +5,41 @@
|
|||
<h1 class="text-gray-900 text-xl font-medium mb-4"><%= t(".page_title") %></h1>
|
||||
<div class="space-y-4">
|
||||
<%= settings_section title: t(".profile_title"), subtitle: t(".profile_subtitle") do %>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex justify-center items-center bg-gray-50 w-24 h-24 rounded-full border border-alpha-black-25">
|
||||
<%= lucide_icon "image-plus", class: "w-6 h-6 text-gray-500" %>
|
||||
<%= form_with model: Current.user, url: settings_profile_path, html: {data: { controller: "profile-image-preview" }} do |form| %>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative flex justify-center items-center bg-gray-50 w-24 h-24 rounded-full border border-alpha-black-25">
|
||||
<div data-profile-image-preview-target="imagePreview">
|
||||
<% profile_image_attached = Current.user.profile_image.attached? %>
|
||||
<% if profile_image_attached %>
|
||||
<div class="h-24 w-24">
|
||||
<%= render "shared/user_profile_image", user: Current.user %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= lucide_icon "image-plus", class: "w-6 h-6 text-gray-500" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= lucide_icon "image-plus", class: "hidden w-6 h-6 text-gray-500", data: { profile_image_preview_target: "template" } %>
|
||||
<div data-profile-image-preview-target="clearBtn" data-action="click->profile-image-preview#clear" class="<%= profile_image_attached ? "" : "hidden" %> cursor-pointer absolute bottom-0 right-0 w-8 h-8 bg-gray-50 rounded-full flex justify-center items-center border border-white border-2">
|
||||
<%= lucide_icon "x", class: "w-4 h-4 text-gray-500" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<p><%= t(".profile_image_type") %></p>
|
||||
<%= form.label :profile_image, t(".profile_image_choose"), class: "inline-block cursor-pointer px-3 py-2 bg-gray-50 text-gray-900 rounded-md text-sm font-medium" %>
|
||||
<%= form.file_field :profile_image, accept: "wimage/png, image/jpeg, image/gif", class: "hidden px-3 py-2 bg-gray-50 text-gray-900 rounded-md text-sm font-medium", data: {profile_image_preview_target: "fileField", action: "change->profile-image-preview#preview"} %>
|
||||
<%= form.hidden_field :delete_profile_image, value: false, data: {profile_image_preview_target: "deleteField"} %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<p><%= t(".profile_image_type") %></p>
|
||||
<button class="cursor-not-allowed px-3 py-2 bg-gray-50 text-gray-900 rounded-md text-sm font-medium" disabled><%= t(".profile_image_choose") %></button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= form_with model: Current.user, url: settings_profile_path, html: { class: "space-y-4" } do |form| %>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="grid grid-cols-2 gap-4 mt-4">
|
||||
<%= form.text_field :first_name, placeholder: "First name", value: Current.user.first_name, label: true %>
|
||||
<%= form.text_field :last_name, placeholder: "Last name", value: Current.user.last_name, label: true %>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<div class="flex justify-end mt-4">
|
||||
<%= form.submit t(".save"), class: "bg-gray-900 text-white rounded-lg px-3 py-2" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= settings_section title: t(".household_title"), subtitle: t(".household_subtitle") do %>
|
||||
<div class="space-y-4">
|
||||
|
|
1
app/views/shared/_user_profile_image.html.erb
Normal file
1
app/views/shared/_user_profile_image.html.erb
Normal file
|
@ -0,0 +1 @@
|
|||
<%= image_tag user.profile_image.variant(:thumbnail), class: "rounded-full w-full h-full object-cover" %>
|
Loading…
Add table
Add a link
Reference in a new issue