init activestorage and add avatar upload in user profile

This commit is contained in:
riggraz
2025-01-03 18:09:03 +01:00
parent bb7d3e8218
commit 2561801bad
27 changed files with 193 additions and 22 deletions

View File

@@ -70,6 +70,12 @@ gem 'sidekiq-cron', '2.0.1'
# Template language
gem 'liquid', '5.5.1'
# S3 for ActiveStorage
gem 'aws-sdk-s3', '1.176.1', require: false
# ActiveStorage validation
gem 'active_storage_validations', '1.4'
group :development, :test do
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]

View File

@@ -39,6 +39,12 @@ GEM
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_storage_validations (1.4.0)
activejob (>= 6.1.4)
activemodel (>= 6.1.4)
activestorage (>= 6.1.4)
activesupport (>= 6.1.4)
marcel (>= 1.0.3)
activejob (6.1.7.9)
activesupport (= 6.1.7.9)
globalid (>= 0.3.6)
@@ -62,6 +68,22 @@ GEM
zeitwerk (~> 2.3)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
aws-eventstream (1.3.0)
aws-partitions (1.1030.0)
aws-sdk-core (3.214.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.96.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.176.1)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.10.1)
aws-eventstream (~> 1, >= 1.0.2)
babel-source (5.8.35)
babel-transpiler (0.7.0)
babel-source (>= 4.0, < 6)
@@ -125,6 +147,7 @@ GEM
jbuilder (2.11.5)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
jmespath (1.6.2)
jsbundling-rails (1.1.1)
railties (>= 6.0.0)
json-schema (5.0.1)
@@ -319,6 +342,8 @@ PLATFORMS
ruby
DEPENDENCIES
active_storage_validations (= 1.4)
aws-sdk-s3 (= 1.176.1)
bootsnap (= 1.12.0)
byebug
capybara (= 3.40.0)

View File

@@ -189,7 +189,7 @@ body {
height: 2px;
}
.gravatar {
.avatar {
border-radius: 100%;
}

View File

@@ -37,7 +37,7 @@
.currentUserAvatar {
@extend
.gravatar,
.avatar,
.align-self-end,
.mr-2;
}

View File

@@ -15,7 +15,7 @@ ul.usersList {
.my-2,
.p-3;
.userGravatar {
.userAvatar {
@extend .mr-3, .align-self-center;
}

View File

@@ -71,7 +71,7 @@
.p-2,
.my-1;
.gravatar {
.avatar {
@extend .align-self-center;
}
@@ -155,7 +155,7 @@
.mutedText,
.mb-2;
.postAuthorAvatar { @extend .gravatar; }
.postAuthorAvatar { @extend .avatar; }
}
.postFooterActions { @extend .d-flex; }

View File

@@ -39,7 +39,8 @@ class ApplicationController < ActionController::Base
:full_name,
:notifications_enabled,
:recap_notification_frequency,
:invitation_token
:invitation_token,
:avatar,
]
devise_parameter_sanitizer.permit(:sign_up, keys: additional_permitted_parameters)

View File

@@ -11,6 +11,7 @@ class CommentsController < ApplicationController
:is_post_update,
:created_at,
:updated_at,
'users.id as user_id', # required for avatar_url
'users.full_name as user_full_name',
'users.email as user_email',
'users.role as user_role',
@@ -18,6 +19,12 @@ class CommentsController < ApplicationController
.where(post_id: params[:post_id])
.left_outer_joins(:user)
.order(created_at: :desc)
.includes(user: { avatar_attachment: :blob }) # Preload avatars
comments = comments.map do |comment|
user_avatar_url = comment.user.avatar.attached? ? url_for(comment.user.avatar) : nil
comment.attributes.merge(user_avatar: user_avatar_url)
end
render json: comments
end

View File

@@ -1,6 +1,5 @@
import * as React from 'react';
import ReactMarkdown from 'react-markdown';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js';
import NewComment from './NewComment';
@@ -10,6 +9,7 @@ import { ReplyFormState } from '../../reducers/replyFormReducer';
import CommentEditForm from './CommentEditForm';
import CommentFooter from './CommentFooter';
import { StaffIcon } from '../common/Icons';
import Avatar from '../common/Avatar';
interface Props {
id: number;
@@ -17,6 +17,7 @@ interface Props {
isPostUpdate: boolean;
userFullName: string;
userEmail: string;
userAvatar?: string;
userRole: number;
createdAt: string;
updatedAt: string;
@@ -70,6 +71,7 @@ class Comment extends React.Component<Props, State> {
isPostUpdate,
userFullName,
userEmail,
userAvatar,
userRole,
createdAt,
updatedAt,
@@ -89,7 +91,7 @@ class Comment extends React.Component<Props, State> {
return (
<div className="comment">
<div className="commentHeader">
<Gravatar email={userEmail} size={28} className="gravatar" />
<Avatar avatar={userAvatar} email={userEmail} size={28} />
<span className="commentAuthor">{userFullName}</span>
{ userRole > 0 && <StaffIcon /> }

View File

@@ -41,6 +41,7 @@ const CommentList = ({
}: Props) => (
<>
{comments.map((comment, i) => {
console.log('comment', comment);
if (comment.parentId === parentId) {
return (
<div className="commentList" key={i}>

View File

@@ -25,7 +25,7 @@ const FeedbackListItem = ({ post, onUpdatePostApprovalStatus, hideRejectButton }
<div className="feedbackListItemIcon">
{
post.userId ?
<Gravatar email={post.userEmail} size={42} title={post.userEmail} className="gravatar userGravatar" />
<Gravatar email={post.userEmail} size={42} title={post.userEmail} className="avatar userAvatar" />
:
<AnonymousIcon size={42} />
}

View File

@@ -94,7 +94,7 @@ class UserEditable extends React.Component<Props, State> {
editMode === false ?
<>
<div className="userInfo">
<Gravatar email={user.email} size={42} className="gravatar userGravatar" />
<Gravatar email={user.email} size={42} className="avatar userAvatar" />
<div className="userFullNameRoleStatus">
<span className="userFullName">{ user.fullName }</span>

View File

@@ -44,7 +44,7 @@ class UserForm extends React.Component<Props, State> {
return (
<div className="userForm">
<Gravatar email={user.email} size={42} className="gravatar userGravatar" />
<Gravatar email={user.email} size={42} className="avatar userAvatar" />
<div className="userFullNameRoleForm">
<span className="userFullName">{ user.fullName }</span>

View File

@@ -42,7 +42,7 @@ const PostUpdateList = ({
postUpdates.map((postUpdate, i) => (
<div className="postUpdateListItem" key={i}>
<div className="postUpdateListItemHeader">
<Gravatar email={postUpdate.userEmail} size={28} className="gravatar" />
<Gravatar email={postUpdate.userEmail} size={28} className="avatar" />
<span>{postUpdate.userFullName}</span>
</div>

View File

@@ -218,7 +218,7 @@ const Invitations = ({ siteName, invitations, currentUserEmail, authenticityToke
invitationsToDisplay.map((invitation, i) => (
<li key={i} className="invitationListItem">
<div className="invitationUserInfo">
<Gravatar email={invitation.email} size={42} className="gravatar userGravatar" />
<Gravatar email={invitation.email} size={42} className="avatar userAvatar" />
<span className="invitationEmail">{ invitation.email }</span>
</div>

View File

@@ -0,0 +1,18 @@
import * as React from 'react';
import Gravatar from 'react-gravatar';
interface Props {
avatar?: string;
email: string;
size?: number;
}
const Avatar = ({avatar, email, size = 28}: Props) => {
if (avatar) {
return <img src={avatar} alt={`${email} avatar`} width={size} height={size} className="avatar" />;
} else {
return <Gravatar email={email} size={size} className="avatar" />;
}
}
export default Avatar;

View File

@@ -5,6 +5,7 @@ interface IComment {
isPostUpdate: boolean;
userFullName: string;
userEmail: string;
userAvatar?: string;
userRole: number;
createdAt: string;
updatedAt: string;

View File

@@ -5,6 +5,7 @@ interface ICommentJSON {
is_post_update: boolean;
user_full_name: string;
user_email: string;
user_avatar?: string;
user_role: number;
created_at: string;
updated_at: string;

View File

@@ -17,6 +17,7 @@ const initialState: IComment = {
isPostUpdate: false,
userFullName: '<Unknown user>',
userEmail: 'example@example.com',
userAvatar: undefined,
userRole: 0,
createdAt: undefined,
updatedAt: undefined,
@@ -36,6 +37,7 @@ const commentReducer = (
isPostUpdate: action.comment.is_post_update,
userFullName: action.comment.user_full_name,
userEmail: action.comment.user_email,
userAvatar: action.comment.user_avatar,
userRole: action.comment.user_role,
createdAt: action.comment.created_at,
updatedAt: action.comment.updated_at,

View File

@@ -11,6 +11,7 @@ class User < ApplicationRecord
has_many :likes, dependent: :destroy
has_many :comments, dependent: :destroy
has_one :api_key, dependent: :destroy
has_one_attached :avatar
enum role: [:user, :moderator, :admin, :owner]
enum status: [:active, :blocked, :deleted]
@@ -28,6 +29,9 @@ class User < ApplicationRecord
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, allow_blank: true, length: { in: 6..128 }
validates :password, presence: true, on: :create
validates :avatar,
content_type: ['image/png', 'image/jpg', 'image/jpeg'],
size: { less_than: 100.kilobytes }
def set_default_role
self.role ||= :user

View File

@@ -27,6 +27,20 @@
<%= f.email_field :email, autocomplete: "email", class: "form-control" %>
</div>
<div class="form-group">
<%= f.label :avatar, t('common.forms.auth.avatar') %>
<p>
<% if resource.avatar.attached? %>
<%= image_tag url_for(resource.avatar), class: 'avatar', size: 48 %>
<% else %>
<%= image_tag current_user.gravatar_url, class: 'avatar', size: 48 %>
<% end %>
</p>
<%= f.file_field :avatar %>
</div>
<% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
<div>
<b><%= t('common.forms.auth.waiting_confirmation', email: resource.unconfirmed_email) %></b>

View File

@@ -42,7 +42,12 @@
<% if user_signed_in? %>
<li class="nav-item dropdown">
<a class="profileToggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<%= image_tag(current_user.gravatar_url, class: 'gravatar', alt: current_user.full_name, size: 24) %>
<% if current_user.avatar.attached? %>
<%= image_tag url_for(current_user.avatar), class: 'avatar', size: 24 %>
<% else %>
<%= image_tag current_user.gravatar_url, class: 'avatar', alt: current_user.full_name, size: 24 %>
<% end %>
<span class="fullname"><%= current_user.full_name.truncate(24) %></span>
<% if current_user.moderator? && Post.pending.length > 0 %>
<span class="notificationDot notificationDotTop"><%= Post.pending.length %></span>

View File

@@ -36,7 +36,7 @@ Rails.application.configure do
# config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX
# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local
config.active_storage.service = :production
# Mount Action Cable outside main process or domain.
# config.action_cable.mount_path = nil

View File

@@ -13,6 +13,7 @@ en:
forms:
auth:
email: 'Email'
avatar: 'Avatar'
full_name: 'Full name'
password: 'Password'
password_confirmation: 'Password confirmation'

View File

@@ -7,12 +7,17 @@ local:
root: <%= Rails.root.join("storage") %>
# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
# service: S3
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
# region: us-east-1
# bucket: your_own_bucket
production:
service: S3
endpoint: storjqualcosa.io
access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
region: us-east-1
bucket: your_own_bucket
http_open_timeout: 5
http_read_timeout: 10
retry_limit: 3
# Remember not to checkin your GCS keyfile to a repository
# google:

View File

@@ -0,0 +1,48 @@
# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
create_table :active_storage_blobs, id: primary_key_type do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.string :service_name, null: false
t.bigint :byte_size, null: false
t.string :checksum, null: false
t.datetime :created_at, null: false
t.index [ :key ], unique: true
end
create_table :active_storage_attachments, id: primary_key_type do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.references :blob, null: false, type: foreign_key_type
t.datetime :created_at, null: false
t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
create_table :active_storage_variant_records, id: primary_key_type do |t|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
t.string :variation_digest, null: false
t.index %i[ blob_id variation_digest ], name: "index_active_storage_variant_records_uniqueness", unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[primary_key_type, foreign_key_type]
end
end

View File

@@ -10,11 +10,39 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2024_11_30_112415) do
ActiveRecord::Schema.define(version: 2025_01_03_152157) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
t.bigint "record_id", null: false
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end
create_table "active_storage_blobs", force: :cascade do |t|
t.string "key", null: false
t.string "filename", null: false
t.string "content_type"
t.text "metadata"
t.string "service_name", null: false
t.bigint "byte_size", null: false
t.string "checksum", null: false
t.datetime "created_at", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
create_table "active_storage_variant_records", force: :cascade do |t|
t.bigint "blob_id", null: false
t.string "variation_digest", null: false
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "api_keys", force: :cascade do |t|
t.bigint "tenant_id", null: false
t.bigint "user_id", null: false
@@ -262,6 +290,8 @@ ActiveRecord::Schema.define(version: 2024_11_30_112415) do
t.index ["tenant_id"], name: "index_webhooks_on_tenant_id"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "api_keys", "tenants"
add_foreign_key "api_keys", "users"
add_foreign_key "boards", "tenants"