3 Commits

Author SHA1 Message Date
Riccardo Graziosi
9176b96167 Update README and remove old links (#481)
- Remove links to demo and docs website from README
- Remove most links across the codebase referring to docs.astuto.io and astuto.io

Goodbye, Astuto 🥲
2025-10-31 15:20:05 +01:00
Riccardo Graziosi
d85bce76ad Delete FUNDING.yml (#462) 2025-03-07 16:33:36 +01:00
Riccardo Graziosi
fb5e6165c3 Disable tenant registration (#461)
* Disable tenant registration
* Disable Stripe subscription management
* Fix OAuth broken link
* Remove managed version info from README
2025-03-03 17:51:26 +01:00
134 changed files with 478 additions and 2148 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1 +0,0 @@
github: riggraz

View File

@@ -42,7 +42,7 @@ RUN RSWAG_SWAGGERIZE=true RAILS_ENV=test bundle exec rake rswag:specs:swaggerize
# Compile assets if production # Compile assets if production
# SECRET_KEY_BASE=1 is a workaround (see https://github.com/rails/rails/issues/32947) # SECRET_KEY_BASE=1 is a workaround (see https://github.com/rails/rails/issues/32947)
RUN if [ "$ENVIRONMENT" = "production" ]; then SECRET_KEY_BASE=1 ACTIVE_STORAGE_PRIVATE_SERVICE=test ./bin/rails assets:precompile; fi RUN if [ "$ENVIRONMENT" = "production" ]; then SECRET_KEY_BASE=1 ./bin/rails assets:precompile; fi
### ###
### Dev stage ### ### Dev stage ###

View File

@@ -70,12 +70,6 @@ gem 'sidekiq-cron', '2.0.1'
# Template language # Template language
gem 'liquid', '5.5.1' 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 group :development, :test do
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]

View File

@@ -39,12 +39,6 @@ GEM
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.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) activejob (6.1.7.9)
activesupport (= 6.1.7.9) activesupport (= 6.1.7.9)
globalid (>= 0.3.6) globalid (>= 0.3.6)
@@ -68,22 +62,6 @@ GEM
zeitwerk (~> 2.3) zeitwerk (~> 2.3)
addressable (2.8.7) addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0) 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-source (5.8.35)
babel-transpiler (0.7.0) babel-transpiler (0.7.0)
babel-source (>= 4.0, < 6) babel-source (>= 4.0, < 6)
@@ -147,7 +125,6 @@ GEM
jbuilder (2.11.5) jbuilder (2.11.5)
actionview (>= 5.0.0) actionview (>= 5.0.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
jmespath (1.6.2)
jsbundling-rails (1.1.1) jsbundling-rails (1.1.1)
railties (>= 6.0.0) railties (>= 6.0.0)
json-schema (5.0.1) json-schema (5.0.1)
@@ -342,8 +319,6 @@ PLATFORMS
ruby ruby
DEPENDENCIES DEPENDENCIES
active_storage_validations (= 1.4)
aws-sdk-s3 (= 1.176.1)
bootsnap (= 1.12.0) bootsnap (= 1.12.0)
byebug byebug
capybara (= 3.40.0) capybara (= 3.40.0)

View File

@@ -1,23 +1,13 @@
<p align="center"> <p align="center">
<a href="https://astuto.io/?utm_campaign=github_logo&utm_source=github.com"> <img width="400" src="./images/logo-and-name.png" />
<img width="400" src="./images/logo-and-name.png" />
</a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://www.producthunt.com/posts/astuto?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-astuto" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=179870&theme=neutral&period=daily" alt="Astuto - An open source customer feedback tool 🦊 | Product Hunt Embed" style="width: 250px; height: 54px;" width="250px" height="54px" /></a> <a href="https://www.producthunt.com/posts/astuto?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-astuto" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=179870&theme=neutral&period=daily" alt="Astuto - An open source customer feedback tool 🦊 | Product Hunt Embed" style="width: 250px; height: 54px;" width="250px" height="54px" /></a>
<br>
<h3 align="center">
<a href="https://feedback.astuto.io/">✨ Try it out</a>
&nbsp;•&nbsp;
<a href="https://astuto.io/?utm_campaign=github_learnmore&utm_source=github.com">📖 Learn more</a>
</h3>
</p> </p>
Astuto is an open source customer feedback tool. It helps you collect, manage and prioritize feedback from your customers, so you can build a better product. Astuto is an open source customer feedback tool. It helps you collect, manage and prioritize feedback from your customers, so you can build a better product.
<a href="https://feedback.astuto.io/"> <img src="./images/hero-image.png" />
<img src="./images/hero-image.png" />
</a>
## Features ## Features
@@ -29,28 +19,12 @@ Astuto is an open source customer feedback tool. It helps you collect, manage an
- **Anonymous Feedback**: enable unregistered users to publish feedback - **Anonymous Feedback**: enable unregistered users to publish feedback
- **... and more**: invitation system, brand customization, recap emails for administrators, private site settings, and more! - **... and more**: invitation system, brand customization, recap emails for administrators, private site settings, and more!
## Documentation
Documentation website is not online anymore. You can read Astuto's documentation from the [GitHub repository](https://github.com/astuto/astuto-docs).
## Get started ## Get started
### Hosted
We offer a hosted solution, so you don't have to provision your own server. This is the easiest and fastest way to get started: you can sign up and start collecting feedback in a few minutes.
[Start your 7-day free trial](https://login.astuto.io/signup) without entering any payment method, then it's 15 €/month with annual subscription or 20 €/month with monthly subscription. [Learn more on astuto.io](https://astuto.io/?utm_campaign=github_getstarted&utm_source=github.com).
With the paid plan:
- You avoid deployment hassles like renting a server, issuing SSL certificates, configuring a mail server and managing updates
- You get some OAuth providers out of the box: Google, Facebook and GitHub are ready to log your users in, no configuration needed
- You get priority support
- You support open source and get our eternal gratitude :)
### Self-hosted
Read the [Deploy with Docker instructions](https://docs.astuto.io/deploy-docker) for the most comprehensive and up to date guide on installing and configuring Astuto.
What you find below are minimal instructions to get you started as quickly as possible:
0. Ensure you have Docker and Docker Compose installed 0. Ensure you have Docker and Docker Compose installed
1. Create an empty folder 1. Create an empty folder
2. Inside that folder, create a `docker-compose.yml` file with the following content: 2. Inside that folder, create a `docker-compose.yml` file with the following content:
@@ -77,21 +51,17 @@ services:
volumes: volumes:
dbdata: dbdata:
``` ```
3. Edit the environment variables to fit your needs. You can find more information about env variables in the [documentation](https://docs.astuto.io/deploy-docker/#2-edit-environment-variables). 3. Edit the environment variables to fit your needs
4. Run `docker compose pull && docker compose up` 4. Run `docker compose pull && docker compose up`
5. You should now have a running instance of Astuto on port 3000. A default user account has been created with credentials email: `admin@example.com`, password: `password`. 5. You should now have a running instance of Astuto on port 3000. A default user account has been created with credentials email: `admin@example.com`, password: `password`.
## Documentation
Check out [docs.astuto.io](https://docs.astuto.io/) to learn how to deploy Astuto, configure custom OAuth providers and webhooks, use our REST API and more!
## Contributing ## Contributing
There are many ways to contribute to Astuto, not just coding. Proposing features, reporting issues, translating to a new language or improving documentation are a few examples! Please read our [contributing guidelines](https://github.com/riggraz/astuto/blob/main/CONTRIBUTING.md) to learn more. There are many ways to contribute to Astuto, not just coding. Proposing features, reporting issues, translating to a new language or improving documentation are a few examples! Please read our [contributing guidelines](https://github.com/riggraz/astuto/blob/main/CONTRIBUTING.md) to learn more.
## Credits ## Credits
Astuto logo and all image assets are credited [here](https://astuto.io/credits). Astuto logo and all image assets are credited [here](https://github.com/astuto/astuto-io/blob/main/src/pages/Credits.jsx).
A huge thank you to code contributors A huge thank you to code contributors

View File

@@ -189,7 +189,7 @@ body {
height: 2px; height: 2px;
} }
.avatar { .gravatar {
border-radius: 100%; border-radius: 100%;
} }
@@ -350,59 +350,4 @@ body {
position: relative; position: relative;
bottom: 8px; bottom: 8px;
scale: 80%; scale: 80%;
}
.dropzone {
border: 2px dashed var(--astuto-grey);
border-radius: 8px;
padding: 8px;
cursor: pointer;
}
.dropzone-disabled {
cursor: not-allowed;
}
.dropzone-accept {
border-color: rgb(0, 189, 0);
}
.dropzone-reject {
border-color: rgb(255, 0, 0);
}
.thumbnailsContainer {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 16px;
.thumbnail {
display: inline-flex;
border-radius: 2px;
border: 1px solid #eaeaea;
margin-bottom: 8px;
margin-right: 8px;
width: 80px;
height: 80px;
padding: 4px;
box-sizing: border-box;
.thumbnailInner {
display: flex;
min-width: 0;
overflow: hidden;
.thumbnailImage {
display: block;
width: auto;
height: 100%;
}
}
&.thumbnailToDelete {
border: 2px solid red;
.thumbnailInner { filter: grayscale(100%); }
}
}
} }

View File

@@ -1,8 +1,6 @@
.commentsContainer { .commentsContainer {
@extend .mt-2; @extend .mt-2;
.attachFilesSectionHidden { display: none; }
.commentForm { .commentForm {
@extend @extend
.form-control, .form-control,
@@ -24,42 +22,22 @@
.flex-column, .flex-column,
.mt-4; .mt-4;
.newCommentBodyForm { .commentBodyForm {
@extend .d-flex; @extend .d-flex;
} }
.newCommentFooter {
@extend
.d-flex,
.justify-content-between,
.align-items-center;
.attachFilesSection {
margin-left: 58px;
margin-top: 1rem;
}
}
.commentIsUpdateForm { .commentIsUpdateForm {
@extend @extend
.d-flex, .d-flex,
.flex-column, .justify-content-between,
.align-self-start,
.mt-3; .mt-3;
&.commentIsUpdateFormWithAttachment { margin-left: 58px;
margin-right: 108px;
.checkboxSwitch { align-self: flex-end; }
}
&.commentIsUpdateFormWithoutAttachment {
margin-left: 58px;
.checkboxSwitch { align-self: flex-start; }
}
} }
.currentUserAvatar { .currentUserAvatar {
@extend @extend
.avatar, .gravatar,
.align-self-end, .align-self-end,
.mr-2; .mr-2;
} }
@@ -75,12 +53,7 @@
} }
.editCommentForm { .editCommentForm {
@extend .my-3; .commentFormContainer { @extend .d-block; }
background-color: rgb(255 255 215);
padding: 16px;
.commentFormContainer, .editCommentFormAttachments { @extend .d-block; }
textarea { textarea {
@extend .my-2; @extend .my-2;
@@ -92,13 +65,6 @@
.justify-content-between; .justify-content-between;
} }
.editCommentFormFooter {
@extend
.d-flex,
.justify-content-between,
.align-items-center;
}
.editCommentFormActions { @extend .d-flex; } .editCommentFormActions { @extend .d-flex; }
} }
@@ -150,17 +116,6 @@
p:nth-child(1) { @extend .m-0; } p:nth-child(1) { @extend .m-0; }
} }
.commentAttachments {
@extend .mb-4;
.commentAttachment {
@extend .mx-2, .p-2;
height: 60px;
border: thin solid var(--astuto-grey-light);
}
}
.commentFooter { .commentFooter {
@extend .d-flex; @extend .d-flex;

View File

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

View File

@@ -71,7 +71,7 @@
.p-2, .p-2,
.my-1; .my-1;
.avatar { .gravatar {
@extend .align-self-center; @extend .align-self-center;
} }
@@ -149,24 +149,13 @@
} }
} }
.postAttachments {
@extend .mb-4;
.postAttachment {
@extend .mx-2, .p-2;
height: 80px;
border: thin solid var(--astuto-grey-light);
}
}
.postFooter { .postFooter {
.postAuthor { .postAuthor {
@extend @extend
.mutedText, .mutedText,
.mb-2; .mb-2;
.postAuthorAvatar { @extend .avatar; } .postAuthorAvatar { @extend .gravatar; }
} }
.postFooterActions { @extend .d-flex; } .postFooterActions { @extend .d-flex; }
@@ -184,7 +173,7 @@
} }
.postEditFormButtons { .postEditFormButtons {
@extend .d-flex, .justify-content-end, .mt-3; @extend .d-flex, .justify-content-end;
} }
#selectPickerBoard { margin-right: 4px !important; } #selectPickerBoard { margin-right: 4px !important; }

View File

@@ -52,27 +52,4 @@
font-size: 18px; font-size: 18px;
} }
.oAuthLogoPreview {
@extend .d-block, .my-2;
position: relative;
display: inline-block;
width: fit-content;
height: fit-content;
}
.oAuthLogoPreview .oAuthLogoPreviewImg {
display: block;
height: 50px;
width: auto;
}
.oAuthLogoPreview.oAuthLogoPreviewShouldDelete {
border: 2px solid red;
.oAuthLogoPreviewImg { filter: grayscale(100%); }
}
.oAuthLogoActions { @extend .d-flex; }
} }

View File

@@ -8,29 +8,4 @@
.generalSiteSettingsSubmit { .generalSiteSettingsSubmit {
@extend .mb-4; @extend .mb-4;
} }
.siteLogoPreview, .siteFaviconPreview {
@extend .d-block, .my-2;
position: relative;
display: inline-block;
width: fit-content; /* Container matches the image size */
height: fit-content; /* Adjusts height to match the image */
}
.siteLogoPreview .siteLogoPreviewImg, .siteFaviconPreview .siteFaviconPreviewImg {
display: block;
height: 50px; /* Fixed height for the image */
width: auto; /* Maintain the aspect ratio */
}
.siteLogoPreview.siteLogoPreviewShouldDelete, .siteFaviconPreview.siteFaviconPreviewShouldDelete {
border: 2px solid red;
.siteLogoPreviewImg, .siteFaviconPreviewImg {
filter: grayscale(100%);
}
}
.siteLogoActions, .siteFaviconActions { @extend .d-flex; }
} }

View File

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

View File

@@ -11,7 +11,6 @@ class CommentsController < ApplicationController
:is_post_update, :is_post_update,
:created_at, :created_at,
:updated_at, :updated_at,
'users.id as user_id', # required for avatar_url
'users.full_name as user_full_name', 'users.full_name as user_full_name',
'users.email as user_email', 'users.email as user_email',
'users.role as user_role', 'users.role as user_role',
@@ -19,17 +18,6 @@ class CommentsController < ApplicationController
.where(post_id: params[:post_id]) .where(post_id: params[:post_id])
.left_outer_joins(:user) .left_outer_joins(:user)
.order(created_at: :desc) .order(created_at: :desc)
.includes(user: { avatar_attachment: :blob }) # Preload avatars
comments = comments.map do |comment|
user_avatar_url = comment.user.avatar.attached? ? comment.user.avatar.blob.url : nil
attachment_urls = comment.attachments.order(:created_at).map { |attachment| attachment.blob.url }
comment.attributes.merge(
attachment_urls: attachment_urls,
user_avatar: user_avatar_url
)
end
render json: comments render json: comments
end end
@@ -38,22 +26,11 @@ class CommentsController < ApplicationController
@comment = Comment.new @comment = Comment.new
@comment.assign_attributes(comment_create_params) @comment.assign_attributes(comment_create_params)
# handle attachments
if Current.tenant.tenant_setting.allow_attachment_upload && params[:comment][:attachments].present?
@comment.attachments.attach(params[:comment][:attachments])
end
if @comment.save if @comment.save
SendNotificationForCommentWorkflow.new(comment: @comment).run SendNotificationForCommentWorkflow.new(comment: @comment).run
render json: @comment.attributes.merge( render json: @comment.attributes.merge(
{ { user_full_name: current_user.full_name, user_email: current_user.email, user_role: current_user.role_before_type_cast }
attachment_urls: @comment.attachments.order(:created_at).map { |attachment| attachment.blob.url },
user_full_name: current_user.full_name,
user_email: current_user.email,
user_avatar: current_user.avatar.attached? ? current_user.avatar.blob.url : nil,
user_role: current_user.role_before_type_cast
}
), status: :created ), status: :created
else else
render json: { render json: {
@@ -66,29 +43,9 @@ class CommentsController < ApplicationController
@comment = Comment.find(params[:id]) @comment = Comment.find(params[:id])
authorize @comment authorize @comment
@comment.assign_attributes(comment_update_params) if @comment.update(comment_update_params)
# handle attachment deletion
if params[:comment][:attachments_to_delete].present? && @comment.attachments.attached?
@comment.attachments.order(:created_at).each_with_index do |attachment, index|
attachment.purge if params[:comment][:attachments_to_delete].include?(index.to_s)
end
end
# handle attachments
if Current.tenant.tenant_setting.allow_attachment_upload && params[:comment][:attachments].present?
@comment.attachments.attach(params[:comment][:attachments])
end
if @comment.save
render json: @comment.attributes.merge( render json: @comment.attributes.merge(
{ { user_full_name: @comment.user.full_name, user_email: @comment.user.email, user_role: @comment.user.role_before_type_cast }
attachment_urls: @comment.attachments.order(:created_at).map { |attachment| attachment.blob.url },
user_full_name: @comment.user.full_name,
user_email: @comment.user.email,
user_avatar: @comment.user.avatar.attached? ? @comment.user.avatar.blob.url : nil,
user_role: @comment.user.role_before_type_cast
}
) )
else else
render json: { render json: {

View File

@@ -7,17 +7,10 @@ class LikesController < ApplicationController
.select( .select(
:id, :id,
:full_name, :full_name,
:email, :email
'users.id as user_id', # required for avatar_url
) )
.left_outer_joins(:user) .left_outer_joins(:user)
.where(post_id: params[:post_id]) .where(post_id: params[:post_id])
.includes(user: { avatar_attachment: :blob }) # Preload avatars
likes = likes.map do |like|
user_avatar_url = like.user.avatar.attached? ? like.user.avatar.blob.url : nil
like.attributes.merge(user_avatar: user_avatar_url)
end
render json: likes render json: likes
end end
@@ -30,7 +23,6 @@ class LikesController < ApplicationController
id: like.id, id: like.id,
full_name: current_user.full_name, full_name: current_user.full_name,
email: current_user.email, email: current_user.email,
user_avatar: current_user.avatar.attached? ? current_user.avatar.blob.url : nil,
}, status: :created }, status: :created
else else
render json: { render json: {

View File

@@ -1,14 +0,0 @@
class LocalFilesController < ApplicationController
def show
blob = ActiveStorage::Blob.find_by(key: params[:key])
if blob.present? && blob.service.is_a?(ActiveStorage::Service::DiskService)
send_file blob.service.path_for(blob.key),
type: blob.content_type, # Set correct MIME type
disposition: :inline, # Show in browser
filename: blob.filename.to_s # Ensure correct filename
else
head :not_found
end
end
end

View File

@@ -170,10 +170,6 @@ class OAuthsController < ApplicationController
@o_auth = OAuth.find(params[:id]) @o_auth = OAuth.find(params[:id])
authorize @o_auth authorize @o_auth
if params[:o_auth][:should_delete_logo] == "true"
@o_auth.logo.purge if @o_auth.logo.attached?
end
if @o_auth.update(o_auth_params) if @o_auth.update(o_auth_params)
render json: to_json_custom(@o_auth) render json: to_json_custom(@o_auth)
else else
@@ -202,7 +198,7 @@ class OAuthsController < ApplicationController
def to_json_custom(o_auth) def to_json_custom(o_auth)
o_auth.as_json( o_auth.as_json(
methods: [:logo_url, :callback_url, :default_o_auth_is_enabled], methods: [:callback_url, :default_o_auth_is_enabled],
except: [:client_secret] except: [:client_secret]
) )
end end
@@ -210,10 +206,6 @@ class OAuthsController < ApplicationController
def o_auth_params def o_auth_params
params params
.require(:o_auth) .require(:o_auth)
.permit( .permit(policy(@o_auth).permitted_attributes)
policy(@o_auth)
.permitted_attributes
.concat([{ additional_params: [:should_delete_logo] }])
)
end end
end end

View File

@@ -4,19 +4,12 @@ class PostStatusChangesController < ApplicationController
.select( .select(
:post_status_id, :post_status_id,
:created_at, :created_at,
'users.id as user_id', # required for avatar_url
'users.full_name as user_full_name', 'users.full_name as user_full_name',
'users.email as user_email', 'users.email as user_email',
) )
.where(post_id: params[:post_id]) .where(post_id: params[:post_id])
.left_outer_joins(:user) .left_outer_joins(:user)
.order(created_at: :asc) .order(created_at: :asc)
.includes(user: { avatar_attachment: :blob }) # Preload avatars
post_status_changes = post_status_changes.map do |post_status_change|
user_avatar_url = post_status_change.user.avatar.attached? ? post_status_change.user.avatar.blob.url : nil
post_status_change.attributes.merge(user_avatar: user_avatar_url)
end
render json: post_status_changes render json: post_status_changes
end end

View File

@@ -32,11 +32,6 @@ class PostsController < ApplicationController
# apply post status filter if present # apply post status filter if present
posts = posts.where(post_status_id: params[:post_status_ids].map { |id| id == "0" ? nil : id }) if params[:post_status_ids].present? posts = posts.where(post_status_id: params[:post_status_ids].map { |id| id == "0" ? nil : id }) if params[:post_status_ids].present?
# check if posts have attachments
posts = posts.map do |post|
post.attributes.merge(has_attachments: post.attachments.attached?)
end
render json: posts render json: posts
end end
@@ -62,11 +57,6 @@ class PostsController < ApplicationController
@post = Post.new(approval_status: approval_status) @post = Post.new(approval_status: approval_status)
@post.assign_attributes(post_create_params(is_anonymous: is_anonymous)) @post.assign_attributes(post_create_params(is_anonymous: is_anonymous))
# handle attachments
if Current.tenant.tenant_setting.allow_attachment_upload && params[:post][:attachments].present? && !is_anonymous
@post.attachments.attach(params[:post][:attachments])
end
if @post.save if @post.save
Follow.create(post_id: @post.id, user_id: current_user.id) unless is_anonymous Follow.create(post_id: @post.id, user_id: current_user.id) unless is_anonymous
@@ -97,7 +87,7 @@ class PostsController < ApplicationController
) )
.eager_load(:user) # left outer join .eager_load(:user) # left outer join
.find(params[:id]) .find(params[:id])
@post_statuses = PostStatus.select(:id, :name, :color).order(order: :asc) @post_statuses = PostStatus.select(:id, :name, :color).order(order: :asc)
@board = @post.board @board = @post.board
@@ -106,7 +96,7 @@ class PostsController < ApplicationController
respond_to do |format| respond_to do |format|
format.html format.html
format.json { render json: @post.as_json.merge(attachment_urls: @post.attachments.order(:created_at).map { |attachment| attachment.blob.url }) } format.json { render json: @post }
end end
end end
@@ -116,18 +106,6 @@ class PostsController < ApplicationController
@post.assign_attributes(post_update_params) @post.assign_attributes(post_update_params)
# handle attachment deletion
if params[:post][:attachments_to_delete].present? && @post.attachments.attached?
@post.attachments.order(:created_at).each_with_index do |attachment, index|
attachment.purge if params[:post][:attachments_to_delete].include?(index.to_s)
end
end
# handle attachments
if Current.tenant.tenant_setting.allow_attachment_upload && params[:post][:attachments].present?
@post.attachments.attach(params[:post][:attachments])
end
if @post.save if @post.save
if @post.post_status_id_previously_changed? if @post.post_status_id_previously_changed?
ExecutePostStatusChangeLogicWorkflow.new( ExecutePostStatusChangeLogicWorkflow.new(
@@ -137,7 +115,7 @@ class PostsController < ApplicationController
).run ).run
end end
render json: @post.as_json.merge(attachment_urls: @post.attachments.order(:created_at).map { |attachment| attachment.blob.url }) render json: @post
else else
render json: { render json: {
error: @post.errors.full_messages error: @post.errors.full_messages
@@ -172,7 +150,6 @@ class PostsController < ApplicationController
:user_id, :user_id,
:board_id, :board_id,
:created_at, :created_at,
'users.id as user_id', # required for avatar_url
'users.email as user_email', 'users.email as user_email',
'users.full_name as user_full_name' 'users.full_name as user_full_name'
) )
@@ -180,12 +157,6 @@ class PostsController < ApplicationController
.where(approval_status: ["pending", "rejected"]) .where(approval_status: ["pending", "rejected"])
.order_by(created_at: :desc) .order_by(created_at: :desc)
.limit(100) .limit(100)
.includes(user: { avatar_attachment: :blob }) # Preload avatars
posts = posts.map do |post|
user_avatar_url = post.user.avatar.attached? ? post.user.avatar.blob.url : nil
post.attributes.merge(user_avatar: user_avatar_url)
end
render json: posts render json: posts
end end
@@ -215,10 +186,6 @@ class PostsController < ApplicationController
def post_update_params def post_update_params
params params
.require(:post) .require(:post)
.permit( .permit(policy(@post).permitted_attributes_for_update)
policy(@post)
.permitted_attributes_for_update
.concat([{ additional_params: [:attachments_to_delete] }])
)
end end
end end

View File

@@ -77,13 +77,6 @@ class RegistrationsController < Devise::RegistrationsController
respond_with_navigational(resource){ redirect_to after_sign_out_path_for(resource_name) } respond_with_navigational(resource){ redirect_to after_sign_out_path_for(resource_name) }
end end
def delete_avatar
user = User.find(current_user.id)
user.avatar.purge
render json: { success: true }, status: :ok
end
def send_set_password_instructions def send_set_password_instructions
user = User.find_by_email(params[:email]) user = User.find_by_email(params[:email])

View File

@@ -11,14 +11,13 @@ class TenantsController < ApplicationController
end end
def show def show
tenant = Current.tenant_or_raise! render json: Current.tenant_or_raise!
tenant.attributes.merge(site_logo_url: tenant.site_logo.attached? ? tenant.site_logo.blob.url : nil)
render json: tenant
end end
def create def create
# NOTE: new tenants registrations disabled
raise "Tenant registration disabled"
@tenant = Tenant.new @tenant = Tenant.new
@tenant.assign_attributes(tenant_create_params) @tenant.assign_attributes(tenant_create_params)
authorize @tenant authorize @tenant
@@ -96,27 +95,7 @@ class TenantsController < ApplicationController
# to avoid unique constraint violation # to avoid unique constraint violation
params[:tenant][:custom_domain] = nil if params[:tenant][:custom_domain].blank? params[:tenant][:custom_domain] = nil if params[:tenant][:custom_domain].blank?
# Handle site logo attachment if @tenant.update(tenant_update_params)
if params[:tenant][:should_delete_site_logo] == "true"
@tenant.site_logo.purge if @tenant.site_logo.attached?
elsif params[:tenant][:site_logo].present?
@tenant.site_logo.purge if @tenant.site_logo.attached?
@tenant.site_logo.attach(params[:tenant][:site_logo])
should_delete_old_site_logo = true
end
# Handle site favicon attachment
if params[:tenant][:should_delete_site_favicon] == "true"
@tenant.site_favicon.purge if @tenant.site_favicon.attached?
elsif params[:tenant][:site_favicon].present?
@tenant.site_favicon.purge if @tenant.site_favicon.attached?
@tenant.site_favicon.attach(params[:tenant][:site_favicon])
end
@tenant.assign_attributes(tenant_update_params)
@tenant.old_site_logo = nil if should_delete_old_site_logo
if @tenant.save
render json: @tenant render json: @tenant
else else
render json: { render json: {
@@ -152,8 +131,7 @@ class TenantsController < ApplicationController
policy(@tenant) policy(@tenant)
.permitted_attributes_for_update .permitted_attributes_for_update
.concat([{ .concat([{
tenant_setting_attributes: policy(@tenant.tenant_setting).permitted_attributes_for_update, tenant_setting_attributes: policy(@tenant.tenant_setting).permitted_attributes_for_update
additional_params: [:should_delete_site_logo, :should_delete_site_favicon]
}]) # in order to permit nested attributes for tenant_setting }]) # in order to permit nested attributes for tenant_setting
) )
end end

View File

@@ -8,10 +8,6 @@ class UsersController < ApplicationController
.all .all
.order(role: :desc, created_at: :desc) .order(role: :desc, created_at: :desc)
@users = @users.map do |user|
user.attributes.merge(avatar_url: user.avatar.attached? ? user.avatar.blob.url : nil)
end
render json: @users render json: @users
end end

View File

@@ -3,8 +3,8 @@ import { ThunkAction } from 'redux-thunk';
import { State } from '../../reducers/rootReducer'; import { State } from '../../reducers/rootReducer';
import ICommentJSON from '../../interfaces/json/IComment'; import ICommentJSON from '../../interfaces/json/IComment';
import buildRequestHeaders from '../../helpers/buildRequestHeaders';
import HttpStatus from '../../constants/http_status'; import HttpStatus from '../../constants/http_status';
import buildFormData from '../../helpers/buildFormData';
export const COMMENT_SUBMIT_START = 'COMMENT_SUBMIT_START'; export const COMMENT_SUBMIT_START = 'COMMENT_SUBMIT_START';
interface CommentSubmitStartAction { interface CommentSubmitStartAction {
@@ -53,24 +53,21 @@ export const submitComment = (
body: string, body: string,
parentId: number, parentId: number,
isPostUpdate: boolean, isPostUpdate: boolean,
attachments: File[],
authenticityToken: string, authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => { ): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(commentSubmitStart(parentId)); dispatch(commentSubmitStart(parentId));
try { try {
let formDataObj = {
'comment[body]': body,
'comment[parent_id]': parentId,
'comment[is_post_update]': isPostUpdate,
'comment[attachments][]': attachments,
};
const requestBody = buildFormData(formDataObj);
const res = await fetch(`/posts/${postId}/comments`, { const res = await fetch(`/posts/${postId}/comments`, {
method: 'POST', method: 'POST',
headers: { 'X-CSRF-Token': authenticityToken }, headers: buildRequestHeaders(authenticityToken),
body: requestBody, body: JSON.stringify({
comment: {
body,
parent_id: parentId,
is_post_update: isPostUpdate,
},
}),
}); });
const json = await res.json(); const json = await res.json();
@@ -79,11 +76,7 @@ export const submitComment = (
} else { } else {
dispatch(commentSubmitFailure(parentId, json.error)); dispatch(commentSubmitFailure(parentId, json.error));
} }
return Promise.resolve(res);
} catch (e) { } catch (e) {
dispatch(commentSubmitFailure(parentId, e)); dispatch(commentSubmitFailure(parentId, e));
return Promise.resolve(null);
} }
} }

View File

@@ -5,7 +5,6 @@ import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders"; import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import ICommentJSON from "../../interfaces/json/IComment"; import ICommentJSON from "../../interfaces/json/IComment";
import { State } from "../../reducers/rootReducer"; import { State } from "../../reducers/rootReducer";
import buildFormData from "../../helpers/buildFormData";
export const COMMENT_UPDATE_START = 'COMMENT_UPDATE_START'; export const COMMENT_UPDATE_START = 'COMMENT_UPDATE_START';
interface CommentUpdateStartAction { interface CommentUpdateStartAction {
@@ -50,25 +49,20 @@ export const updateComment = (
commentId: number, commentId: number,
body: string, body: string,
isPostUpdate: boolean, isPostUpdate: boolean,
attachmentsToDelete: number[],
attachments: File[],
authenticityToken: string, authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => { ): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(commentUpdateStart()); dispatch(commentUpdateStart());
try { try {
let formDataObj = {
'comment[body]': body,
'comment[is_post_update]': isPostUpdate,
'comment[attachments_to_delete][]': attachmentsToDelete,
'comment[attachments][]': attachments,
};
const requestBody = buildFormData(formDataObj);
const res = await fetch(`/posts/${postId}/comments/${commentId}`, { const res = await fetch(`/posts/${postId}/comments/${commentId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'X-CSRF-Token': authenticityToken }, headers: buildRequestHeaders(authenticityToken),
body: requestBody, body: JSON.stringify({
comment: {
body,
is_post_update: isPostUpdate,
},
}),
}); });
const json = await res.json(); const json = await res.json();

View File

@@ -2,9 +2,9 @@ import { Action } from "redux";
import { ThunkAction } from "redux-thunk"; import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status"; import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import { IOAuth, IOAuthJSON, oAuthJS2JSON } from "../../interfaces/IOAuth"; import { IOAuth, IOAuthJSON, oAuthJS2JSON } from "../../interfaces/IOAuth";
import { State } from "../../reducers/rootReducer"; import { State } from "../../reducers/rootReducer";
import buildFormData from "../../helpers/buildFormData";
export const OAUTH_SUBMIT_START = 'OAUTH_SUBMIT_START'; export const OAUTH_SUBMIT_START = 'OAUTH_SUBMIT_START';
interface OAuthSubmitStartAction { interface OAuthSubmitStartAction {
@@ -46,26 +46,20 @@ const oAuthSubmitFailure = (error: string): OAuthSubmitFailureAction => ({
export const submitOAuth = ( export const submitOAuth = (
oAuth: IOAuth, oAuth: IOAuth,
oAuthLogo: File = null,
authenticityToken: string, authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => { ): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(oAuthSubmitStart()); dispatch(oAuthSubmitStart());
let formDataObj = {};
Object.entries(oAuthJS2JSON(oAuth)).forEach(([key, value]) => {
formDataObj[`o_auth[${key}]`] = value;
});
formDataObj['o_auth[logo]'] = oAuthLogo;
formDataObj['o_auth[is_enabled]'] = false;
const body = buildFormData(formDataObj);
try { try {
const res = await fetch(`/o_auths`, { const res = await fetch(`/o_auths`, {
method: 'POST', method: 'POST',
headers: { 'X-CSRF-Token': authenticityToken }, headers: buildRequestHeaders(authenticityToken),
body, body: JSON.stringify({
o_auth: {
...oAuthJS2JSON(oAuth),
is_enabled: false,
},
}),
}); });
const json = await res.json(); const json = await res.json();

View File

@@ -6,7 +6,6 @@ import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders"; import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import { IOAuthJSON } from "../../interfaces/IOAuth"; import { IOAuthJSON } from "../../interfaces/IOAuth";
import { State } from "../../reducers/rootReducer"; import { State } from "../../reducers/rootReducer";
import buildFormData from "../../helpers/buildFormData";
export const OAUTH_UPDATE_START = 'OAUTH_UPDATE_START'; export const OAUTH_UPDATE_START = 'OAUTH_UPDATE_START';
interface OAuthUpdateStartAction { interface OAuthUpdateStartAction {
@@ -50,7 +49,6 @@ interface UpdateOAuthParams {
id: number; id: number;
form?: ISiteSettingsOAuthForm; form?: ISiteSettingsOAuthForm;
isEnabled?: boolean; isEnabled?: boolean;
shouldDeleteLogo?: boolean;
authenticityToken: string; authenticityToken: string;
} }
@@ -58,7 +56,6 @@ export const updateOAuth = ({
id, id,
form = null, form = null,
isEnabled = null, isEnabled = null,
shouldDeleteLogo = false,
authenticityToken, authenticityToken,
}: UpdateOAuthParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => { }: UpdateOAuthParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(oAuthUpdateStart()); dispatch(oAuthUpdateStart());
@@ -66,7 +63,7 @@ export const updateOAuth = ({
const o_auth = Object.assign({}, const o_auth = Object.assign({},
form !== null ? { form !== null ? {
name: form.name, name: form.name,
logo: form.logo ? form.logo : null, logo: form.logo,
client_id: form.clientId, client_id: form.clientId,
client_secret: form.clientSecret, client_secret: form.clientSecret,
authorize_url: form.authorizeUrl, authorize_url: form.authorizeUrl,
@@ -79,20 +76,11 @@ export const updateOAuth = ({
isEnabled !== null ? {is_enabled: isEnabled} : null, isEnabled !== null ? {is_enabled: isEnabled} : null,
); );
let formDataObj = {};
Object.entries(o_auth).forEach(([key, value]) => {
formDataObj[`o_auth[${key}]`] = value;
});
formDataObj['o_auth[should_delete_logo]'] = shouldDeleteLogo.toString();
const body = buildFormData(formDataObj);
try { try {
const res = await fetch(`/o_auths/${id}`, { const res = await fetch(`/o_auths/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'X-CSRF-Token': authenticityToken }, headers: buildRequestHeaders(authenticityToken),
body, body: JSON.stringify({o_auth}),
}); });
const json = await res.json(); const json = await res.json();

View File

@@ -5,7 +5,6 @@ import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import IPostJSON from "../../interfaces/json/IPost"; import IPostJSON from "../../interfaces/json/IPost";
import { State } from "../../reducers/rootReducer"; import { State } from "../../reducers/rootReducer";
import { PostApprovalStatus } from "../../interfaces/IPost"; import { PostApprovalStatus } from "../../interfaces/IPost";
import buildFormData from "../../helpers/buildFormData";
export const POST_UPDATE_START = 'POST_UPDATE_START'; export const POST_UPDATE_START = 'POST_UPDATE_START';
interface PostUpdateStartAction { interface PostUpdateStartAction {
@@ -51,28 +50,22 @@ export const updatePost = (
description: string, description: string,
boardId: number, boardId: number,
postStatusId: number, postStatusId: number,
attachmentsToDelete: number[],
attachments: File[],
authenticityToken: string, authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => { ): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(postUpdateStart()); dispatch(postUpdateStart());
try { try {
let formDataObj = {
'post[title]': title,
'post[description]': description,
'post[board_id]': boardId,
'post[post_status_id]': postStatusId,
'post[attachments_to_delete][]': attachmentsToDelete,
'post[attachments][]': attachments,
};
const body = buildFormData(formDataObj);
const res = await fetch(`/posts/${id}`, { const res = await fetch(`/posts/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'X-CSRF-Token': authenticityToken }, headers: buildRequestHeaders(authenticityToken),
body, body: JSON.stringify({
post: {
title,
description,
board_id: boardId,
post_status_id: postStatusId,
}
}),
}); });
const json = await res.json(); const json = await res.json();

View File

@@ -2,10 +2,10 @@ import { Action } from "redux";
import { ThunkAction } from "redux-thunk"; import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status"; import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import ITenantSetting from "../../interfaces/ITenantSetting"; import ITenantSetting from "../../interfaces/ITenantSetting";
import ITenantJSON from "../../interfaces/json/ITenant"; import ITenantJSON from "../../interfaces/json/ITenant";
import { State } from "../../reducers/rootReducer"; import { State } from "../../reducers/rootReducer";
import buildFormData from "../../helpers/buildFormData";
export const TENANT_UPDATE_START = 'TENANT_UPDATE_START'; export const TENANT_UPDATE_START = 'TENANT_UPDATE_START';
interface TenantUpdateStartAction { interface TenantUpdateStartAction {
@@ -47,11 +47,7 @@ const tenantUpdateFailure = (error: string): TenantUpdateFailureAction => ({
interface UpdateTenantParams { interface UpdateTenantParams {
siteName?: string; siteName?: string;
siteLogo?: File; siteLogo?: string;
shouldDeleteSiteLogo?: boolean;
oldSiteLogo?: string;
siteFavicon?: File;
shouldDeleteSiteFavicon?: boolean;
tenantSetting?: ITenantSetting; tenantSetting?: ITenantSetting;
locale?: string; locale?: string;
customDomain?: string; customDomain?: string;
@@ -61,10 +57,6 @@ interface UpdateTenantParams {
export const updateTenant = ({ export const updateTenant = ({
siteName = null, siteName = null,
siteLogo = null, siteLogo = null,
shouldDeleteSiteLogo = null,
oldSiteLogo = null,
siteFavicon = null,
shouldDeleteSiteFavicon = null,
tenantSetting = null, tenantSetting = null,
locale = null, locale = null,
customDomain = null, customDomain = null,
@@ -72,32 +64,24 @@ export const updateTenant = ({
}: UpdateTenantParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => { }: UpdateTenantParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(tenantUpdateStart()); dispatch(tenantUpdateStart());
try { const tenant = Object.assign({},
let formDataObj = { siteName !== null ? { site_name: siteName } : null,
'tenant[site_name]': siteName, siteLogo !== null ? { site_logo: siteLogo } : null,
'tenant[site_logo]': siteLogo, locale !== null ? { locale } : null,
'tenant[should_delete_site_logo]': shouldDeleteSiteLogo.toString(), customDomain !== null ? { custom_domain: customDomain } : null,
'tenant[old_site_logo]': oldSiteLogo, );
'tenant[site_favicon]': siteFavicon,
'tenant[should_delete_site_favicon]': shouldDeleteSiteFavicon.toString(),
'tenant[locale]': locale,
'tenant[custom_domain]': customDomain,
}
if (tenantSetting) { try {
Object.entries(tenantSetting).forEach(([key, value]) => { const body = JSON.stringify({
formDataObj[`tenant[tenant_setting_attributes][${key}]`] = value; tenant: {
}); ...tenant,
} tenant_setting_attributes: tenantSetting,
},
const body = buildFormData(formDataObj); });
const res = await fetch(`/tenants/0`, { const res = await fetch(`/tenants/0`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: buildRequestHeaders(authenticityToken),
'X-CSRF-Token': authenticityToken,
// do not set Content-Type header when using FormData
},
body, body,
}); });
const json = await res.json(); const json = await res.json();

View File

@@ -125,7 +125,7 @@ const Billing = ({
<p>Subscription {isExpired ? 'expired' : 'expires'} on {subscriptionEndsAtFormatted}</p> <p>Subscription {isExpired ? 'expired' : 'expires'} on {subscriptionEndsAtFormatted}</p>
} }
{ {/* {
(tenantBilling.status === TENANT_BILLING_STATUS_TRIAL) && chosenPrice === null && (tenantBilling.status === TENANT_BILLING_STATUS_TRIAL) && chosenPrice === null &&
<PricingTable <PricingTable
prices={prices} prices={prices}
@@ -169,16 +169,20 @@ const Billing = ({
You will be redirected to Stripe, our billing partner. You will be redirected to Stripe, our billing partner.
</SmallMutedText> </SmallMutedText>
</div> </div>
} } */}
<div className="billingUsefulLinks"> <p>
We do not accept new subscriptions right now.
</p>
{/* <div className="billingUsefulLinks">
<ActionLink onClick={() => window.open('https://astuto.io/terms-of-service', '_blank')} icon={<LearnMoreIcon />}> <ActionLink onClick={() => window.open('https://astuto.io/terms-of-service', '_blank')} icon={<LearnMoreIcon />}>
Terms of Service Terms of Service
</ActionLink> </ActionLink>
<ActionLink onClick={() => window.open('https://astuto.io/privacy-policy', '_blank')} icon={<LearnMoreIcon />}> <ActionLink onClick={() => window.open('https://astuto.io/privacy-policy', '_blank')} icon={<LearnMoreIcon />}>
Privacy Policy Privacy Policy
</ActionLink> </ActionLink>
</div> </div> */}
</Box> </Box>
); );
} }

View File

@@ -115,7 +115,6 @@ class BoardP extends React.Component<Props> {
<Sidebar> <Sidebar>
<NewPost <NewPost
board={board} board={board}
tenantSetting={tenantSetting}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
currentUserFullName={currentUserFullName} currentUserFullName={currentUserFullName}
isAnonymousFeedbackAllowed={tenantSetting.allow_anonymous_feedback} isAnonymousFeedbackAllowed={tenantSetting.allow_anonymous_feedback}

View File

@@ -11,16 +11,12 @@ import {
import Button from '../common/Button'; import Button from '../common/Button';
import IBoard from '../../interfaces/IBoard'; import IBoard from '../../interfaces/IBoard';
import buildRequestHeaders from '../../helpers/buildRequestHeaders';
import HttpStatus from '../../constants/http_status'; import HttpStatus from '../../constants/http_status';
import { POST_APPROVAL_STATUS_APPROVED } from '../../interfaces/IPost'; import { POST_APPROVAL_STATUS_APPROVED } from '../../interfaces/IPost';
import ActionLink from '../common/ActionLink';
import { CancelIcon } from '../common/Icons';
import buildFormData from '../../helpers/buildFormData';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props { interface Props {
board: IBoard; board: IBoard;
tenantSetting: ITenantSetting;
isLoggedIn: boolean; isLoggedIn: boolean;
currentUserFullName: string; currentUserFullName: string;
isAnonymousFeedbackAllowed: boolean; isAnonymousFeedbackAllowed: boolean;
@@ -38,7 +34,6 @@ interface State {
title: string; title: string;
description: string; description: string;
attachments: File[];
isSubmissionAnonymous: boolean; isSubmissionAnonymous: boolean;
// Honeypot anti-spam measure // Honeypot anti-spam measure
@@ -60,7 +55,6 @@ class NewPost extends React.Component<Props, State> {
title: '', title: '',
description: '', description: '',
attachments: [],
isSubmissionAnonymous: false, isSubmissionAnonymous: false,
dnf1: '', dnf1: '',
@@ -70,7 +64,6 @@ class NewPost extends React.Component<Props, State> {
this.toggleForm = this.toggleForm.bind(this); this.toggleForm = this.toggleForm.bind(this);
this.onTitleChange = this.onTitleChange.bind(this); this.onTitleChange = this.onTitleChange.bind(this);
this.onDescriptionChange = this.onDescriptionChange.bind(this); this.onDescriptionChange = this.onDescriptionChange.bind(this);
this.onAttachmentsChange = this.onAttachmentsChange.bind(this);
this.submitForm = this.submitForm.bind(this); this.submitForm = this.submitForm.bind(this);
this.onDnf1Change = this.onDnf1Change.bind(this) this.onDnf1Change = this.onDnf1Change.bind(this)
@@ -99,12 +92,6 @@ class NewPost extends React.Component<Props, State> {
}); });
} }
onAttachmentsChange(attachments: File[]) {
this.setState({
attachments,
});
}
onDnf1Change(dnf1: string) { onDnf1Change(dnf1: string) {
this.setState({ this.setState({
dnf1, dnf1,
@@ -128,7 +115,7 @@ class NewPost extends React.Component<Props, State> {
const boardId = this.props.board.id; const boardId = this.props.board.id;
const { authenticityToken, componentRenderedAt } = this.props; const { authenticityToken, componentRenderedAt } = this.props;
const { title, description, attachments, isSubmissionAnonymous, dnf1, dnf2 } = this.state; const { title, description, isSubmissionAnonymous, dnf1, dnf2 } = this.state;
if (title === '') { if (title === '') {
this.setState({ this.setState({
@@ -139,23 +126,22 @@ class NewPost extends React.Component<Props, State> {
} }
try { try {
let formDataObj = {
'post[title]': title,
'post[description]': description,
'post[attachments][]': attachments,
'post[board_id]': boardId,
'post[is_anonymous]': isSubmissionAnonymous.toString(),
'post[dnf1]': dnf1,
'post[dnf2]': dnf2,
'post[form_rendered_at]': componentRenderedAt,
};
const body = buildFormData(formDataObj);
const res = await fetch('/posts', { const res = await fetch('/posts', {
method: 'POST', method: 'POST',
headers: { 'X-CSRF-Token': authenticityToken }, headers: buildRequestHeaders(authenticityToken),
body: body, body: JSON.stringify({
post: {
title,
description,
board_id: boardId,
is_anonymous: isSubmissionAnonymous,
dnf1,
dnf2,
form_rendered_at: componentRenderedAt,
},
}),
}); });
const json = await res.json(); const json = await res.json();
this.setState({isLoading: false}); this.setState({isLoading: false});
@@ -191,7 +177,6 @@ class NewPost extends React.Component<Props, State> {
render() { render() {
const { const {
board, board,
tenantSetting,
isLoggedIn, isLoggedIn,
currentUserFullName, currentUserFullName,
isAnonymousFeedbackAllowed isAnonymousFeedbackAllowed
@@ -205,7 +190,6 @@ class NewPost extends React.Component<Props, State> {
title, title,
description, description,
attachments,
isSubmissionAnonymous, isSubmissionAnonymous,
dnf1, dnf1,
@@ -224,30 +208,31 @@ class NewPost extends React.Component<Props, State> {
{board.description} {board.description}
</ReactMarkdown> </ReactMarkdown>
{ <Button
showForm ? onClick={() => {
<ActionLink
onClick={this.toggleForm} if (showForm) {
icon={<CancelIcon />} this.toggleForm();
> return;
{I18n.t('common.buttons.cancel')} }
</ActionLink>
: if (isLoggedIn) {
<Button this.toggleForm();
onClick={() => { this.setState({ isSubmissionAnonymous: false });
if (isLoggedIn) { } else {
this.toggleForm(); window.location.href = '/users/sign_in';
this.setState({ isSubmissionAnonymous: false }); }
} else { }}
window.location.href = '/users/sign_in'; className="submitBtn"
} outline={showForm}
}} >
className="submitBtn" {
outline={showForm} showForm ?
> I18n.t('board.new_post.cancel_button')
{I18n.t('board.new_post.submit_button')} :
</Button> I18n.t('board.new_post.submit_button')
} }
</Button>
{ {
(isAnonymousFeedbackAllowed && !showForm) && (isAnonymousFeedbackAllowed && !showForm) &&
@@ -267,14 +252,12 @@ class NewPost extends React.Component<Props, State> {
} }
{ {
showForm && showForm ?
<NewPostForm <NewPostForm
title={title} title={title}
description={description} description={description}
attachments={attachments}
handleTitleChange={this.onTitleChange} handleTitleChange={this.onTitleChange}
handleDescriptionChange={this.onDescriptionChange} handleDescriptionChange={this.onDescriptionChange}
handleAttachmentsChange={this.onAttachmentsChange}
handleSubmit={this.submitForm} handleSubmit={this.submitForm}
@@ -283,10 +266,11 @@ class NewPost extends React.Component<Props, State> {
handleDnf1Change={this.onDnf1Change} handleDnf1Change={this.onDnf1Change}
handleDnf2Change={this.onDnf2Change} handleDnf2Change={this.onDnf2Change}
tenantSetting={tenantSetting}
currentUserFullName={currentUserFullName} currentUserFullName={currentUserFullName}
isSubmissionAnonymous={isSubmissionAnonymous} isSubmissionAnonymous={isSubmissionAnonymous}
/> />
:
null
} }
{ isLoading ? <Spinner /> : null } { isLoading ? <Spinner /> : null }

View File

@@ -4,16 +4,12 @@ import I18n from 'i18n-js';
import Button from '../common/Button'; import Button from '../common/Button';
import { SmallMutedText } from '../common/CustomTexts'; import { SmallMutedText } from '../common/CustomTexts';
import { MarkdownIcon } from '../common/Icons'; import { MarkdownIcon } from '../common/Icons';
import Dropzone from '../common/Dropzone';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props { interface Props {
title: string; title: string;
description: string; description: string;
attachments: File[];
handleTitleChange(title: string): void; handleTitleChange(title: string): void;
handleDescriptionChange(description: string): void; handleDescriptionChange(description: string): void;
handleAttachmentsChange(attachments: File[]): void;
handleSubmit(e: object): void; handleSubmit(e: object): void;
@@ -22,7 +18,6 @@ interface Props {
handleDnf1Change(dnf1: string): void; handleDnf1Change(dnf1: string): void;
handleDnf2Change(dnf2: string): void; handleDnf2Change(dnf2: string): void;
tenantSetting: ITenantSetting;
currentUserFullName: string; currentUserFullName: string;
isSubmissionAnonymous: boolean; isSubmissionAnonymous: boolean;
} }
@@ -30,10 +25,8 @@ interface Props {
const NewPostForm = ({ const NewPostForm = ({
title, title,
description, description,
attachments,
handleTitleChange, handleTitleChange,
handleDescriptionChange, handleDescriptionChange,
handleAttachmentsChange,
handleSubmit, handleSubmit,
@@ -42,14 +35,11 @@ const NewPostForm = ({
handleDnf1Change, handleDnf1Change,
handleDnf2Change, handleDnf2Change,
tenantSetting,
currentUserFullName, currentUserFullName,
isSubmissionAnonymous, isSubmissionAnonymous,
}: Props) => ( }: Props) => (
<div className="newPostForm"> <div className="newPostForm">
<form encType="multipart/form-data"> <form>
{ /* Title */ }
<div className="form-group"> <div className="form-group">
<input <input
type="text" type="text"
@@ -94,7 +84,6 @@ const NewPostForm = ({
className="form-control" className="form-control"
/> />
{ /* Description */ }
<div className="form-group"> <div className="form-group">
<textarea <textarea
value={description} value={description}
@@ -110,19 +99,6 @@ const NewPostForm = ({
</div> </div>
</div> </div>
{ /* Attachments */ }
{
tenantSetting.allow_attachment_upload && !isSubmissionAnonymous &&
<div className="form-group">
<Dropzone
files={attachments}
setFiles={handleAttachmentsChange}
maxSizeKB={2048}
maxFiles={5}
/>
</div>
}
<Button onClick={e => handleSubmit(e)} className="submitBtn d-block mx-auto"> <Button onClick={e => handleSubmit(e)} className="submitBtn d-block mx-auto">
{I18n.t('board.new_post.submit_button')} {I18n.t('board.new_post.submit_button')}
</Button> </Button>

View File

@@ -63,7 +63,6 @@ const PostList = ({
showLikeButtons={showLikeButtons} showLikeButtons={showLikeButtons}
liked={post.liked} liked={post.liked}
commentsCount={post.commentsCount} commentsCount={post.commentsCount}
hasAttachments={post.hasAttachments}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
authenticityToken={authenticityToken} authenticityToken={authenticityToken}

View File

@@ -6,7 +6,6 @@ import CommentsNumber from '../common/CommentsNumber';
import PostStatusLabel from '../common/PostStatusLabel'; import PostStatusLabel from '../common/PostStatusLabel';
import IPostStatus from '../../interfaces/IPostStatus'; import IPostStatus from '../../interfaces/IPostStatus';
import { ImageIcon } from '../common/Icons';
interface Props { interface Props {
id: number; id: number;
@@ -19,7 +18,6 @@ interface Props {
showLikeButtons: boolean; showLikeButtons: boolean;
liked: number; liked: number;
commentsCount: number; commentsCount: number;
hasAttachments: boolean;
isLoggedIn: boolean; isLoggedIn: boolean;
authenticityToken: string; authenticityToken: string;
@@ -36,7 +34,6 @@ const PostListItem = ({
showLikeButtons, showLikeButtons,
liked, liked,
commentsCount, commentsCount,
hasAttachments,
isLoggedIn, isLoggedIn,
authenticityToken, authenticityToken,
@@ -61,10 +58,7 @@ const PostListItem = ({
<div className="postDetails"> <div className="postDetails">
<CommentsNumber number={commentsCount} /> <CommentsNumber number={commentsCount} />
{ postStatus ? <PostStatusLabel {...postStatus} /> : null }
{ postStatus && <PostStatusLabel {...postStatus} /> }
{ hasAttachments && <span style={{marginLeft: '4px'}}><ImageIcon /></span> }
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import NewComment from './NewComment'; import NewComment from './NewComment';
@@ -9,18 +10,13 @@ import { ReplyFormState } from '../../reducers/replyFormReducer';
import CommentEditForm from './CommentEditForm'; import CommentEditForm from './CommentEditForm';
import CommentFooter from './CommentFooter'; import CommentFooter from './CommentFooter';
import { StaffIcon } from '../common/Icons'; import { StaffIcon } from '../common/Icons';
import Avatar from '../common/Avatar';
import CommentAttachments from './CommentAttachments';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props { interface Props {
id: number; id: number;
body: string; body: string;
isPostUpdate: boolean; isPostUpdate: boolean;
attachmentUrls?: string[];
userFullName: string; userFullName: string;
userEmail: string; userEmail: string;
userAvatar?: string;
userRole: number; userRole: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -29,15 +25,13 @@ interface Props {
handleToggleCommentReply(): void; handleToggleCommentReply(): void;
handleCommentReplyBodyChange(e: React.FormEvent): void; handleCommentReplyBodyChange(e: React.FormEvent): void;
handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean, attachments: File[], onSuccess: Function): void; handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean): void;
handleUpdateComment(commentId: number, body: string, isPostUpdate: boolean, attachmentsToDelete: number[], attachments: File[], onSuccess: Function): void; handleUpdateComment(commentId: number, body: string, isPostUpdate: boolean, onSuccess: Function): void;
handleDeleteComment(id: number): void; handleDeleteComment(id: number): void;
tenantSetting: ITenantSetting;
isLoggedIn: boolean; isLoggedIn: boolean;
isPowerUser: boolean; isPowerUser: boolean;
currentUserEmail: string; currentUserEmail: string;
currentUserAvatar?: string;
} }
interface State { interface State {
@@ -60,13 +54,11 @@ class Comment extends React.Component<Props, State> {
this.setState({editMode: !this.state.editMode}); this.setState({editMode: !this.state.editMode});
} }
_handleUpdateComment(body: string, isPostUpdate: boolean, attachmentsToDelete: number[], attachments: File[]) { _handleUpdateComment(body: string, isPostUpdate: boolean) {
this.props.handleUpdateComment( this.props.handleUpdateComment(
this.props.id, this.props.id,
body, body,
isPostUpdate, isPostUpdate,
attachmentsToDelete,
attachments,
this.toggleEditMode, this.toggleEditMode,
); );
} }
@@ -76,10 +68,8 @@ class Comment extends React.Component<Props, State> {
id, id,
body, body,
isPostUpdate, isPostUpdate,
attachmentUrls,
userFullName, userFullName,
userEmail, userEmail,
userAvatar,
userRole, userRole,
createdAt, createdAt,
updatedAt, updatedAt,
@@ -91,26 +81,26 @@ class Comment extends React.Component<Props, State> {
handleSubmitComment, handleSubmitComment,
handleDeleteComment, handleDeleteComment,
tenantSetting,
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
currentUserEmail, currentUserEmail,
currentUserAvatar,
} = this.props; } = this.props;
return ( return (
<div className="comment"> <div className="comment">
<div className="commentHeader"> <div className="commentHeader">
<Avatar avatarUrl={userAvatar} email={userEmail} size={28} /> <Gravatar email={userEmail} size={28} className="gravatar" />
<span className="commentAuthor">{userFullName}</span> <span className="commentAuthor">{userFullName}</span>
{ userRole > 0 && <StaffIcon /> } { userRole > 0 && <StaffIcon /> }
{ {
isPostUpdate && isPostUpdate ?
<span className="postUpdateBadge"> <span className="postUpdateBadge">
{I18n.t('post.comments.post_update_badge')} {I18n.t('post.comments.post_update_badge')}
</span> </span>
:
null
} }
</div> </div>
@@ -120,10 +110,8 @@ class Comment extends React.Component<Props, State> {
id={id} id={id}
initialBody={body} initialBody={body}
initialIsPostUpdate={isPostUpdate} initialIsPostUpdate={isPostUpdate}
attachmentUrls={attachmentUrls}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
tenantSetting={tenantSetting}
handleUpdateComment={this._handleUpdateComment} handleUpdateComment={this._handleUpdateComment}
toggleEditMode={this.toggleEditMode} toggleEditMode={this.toggleEditMode}
@@ -138,11 +126,6 @@ class Comment extends React.Component<Props, State> {
{body} {body}
</ReactMarkdown> </ReactMarkdown>
{
attachmentUrls && attachmentUrls.length > 0 &&
<CommentAttachments attachmentUrls={attachmentUrls} />
}
<CommentFooter <CommentFooter
id={id} id={id}
createdAt={createdAt} createdAt={createdAt}
@@ -170,11 +153,9 @@ class Comment extends React.Component<Props, State> {
handlePostUpdateFlag={() => null} handlePostUpdateFlag={() => null}
handleSubmit={handleSubmitComment} handleSubmit={handleSubmitComment}
allowAttachmentUpload={tenantSetting.allow_attachment_upload}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
userEmail={currentUserEmail} userEmail={currentUserEmail}
userAvatar={currentUserAvatar}
/> />
: :
null null

View File

@@ -1,20 +0,0 @@
import React from 'react';
interface Props {
attachmentUrls?: string[];
}
const CommentAttachments = ({ attachmentUrls = [] }: Props) => (
attachmentUrls.length > 0 &&
<div className="commentAttachments">
{
attachmentUrls.map((url, index) => (
<a key={index} href={url} target="_blank">
<img src={url} className="commentAttachment" />
</a>
))
}
</div>
);
export default CommentAttachments;

View File

@@ -3,28 +3,22 @@ import I18n from 'i18n-js';
import Button from '../common/Button'; import Button from '../common/Button';
import Switch from '../common/Switch'; import Switch from '../common/Switch';
import ActionLink from '../common/ActionLink'; import ActionLink from '../common/ActionLink';
import { CancelIcon, DeleteIcon, MarkdownIcon } from '../common/Icons'; import { CancelIcon, MarkdownIcon } from '../common/Icons';
import Dropzone from '../common/Dropzone';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props { interface Props {
id: number; id: number;
initialBody: string; initialBody: string;
initialIsPostUpdate: boolean; initialIsPostUpdate: boolean;
attachmentUrls?: string[];
isPowerUser: boolean; isPowerUser: boolean;
tenantSetting: ITenantSetting;
handleUpdateComment(body: string, isPostUpdate: boolean, attachmentsToDelete: number[], attachments: File[]): void; handleUpdateComment(body: string, isPostUpdate: boolean): void;
toggleEditMode(): void; toggleEditMode(): void;
} }
interface State { interface State {
body: string; body: string;
isPostUpdate: boolean; isPostUpdate: boolean;
attachmentsToDelete: number[];
attachments: File[];
} }
class CommentEditForm extends React.Component<Props, State> { class CommentEditForm extends React.Component<Props, State> {
@@ -34,14 +28,10 @@ class CommentEditForm extends React.Component<Props, State> {
this.state = { this.state = {
body: '', body: '',
isPostUpdate: false, isPostUpdate: false,
attachmentsToDelete: [],
attachments: [],
}; };
this.handleCommentBodyChange = this.handleCommentBodyChange.bind(this); this.handleCommentBodyChange = this.handleCommentBodyChange.bind(this);
this.handleCommentIsPostUpdateChange = this.handleCommentIsPostUpdateChange.bind(this); this.handleCommentIsPostUpdateChange = this.handleCommentIsPostUpdateChange.bind(this);
this.handleAttachmentsToDeleteChange = this.handleAttachmentsToDeleteChange.bind(this);
this.handleAttachmentsChange = this.handleAttachmentsChange.bind(this);
} }
componentDidMount() { componentDidMount() {
@@ -59,17 +49,9 @@ class CommentEditForm extends React.Component<Props, State> {
this.setState({ isPostUpdate: newIsPostUpdate }); this.setState({ isPostUpdate: newIsPostUpdate });
} }
handleAttachmentsToDeleteChange(newAttachmentsToDelete: number[]) {
this.setState({ attachmentsToDelete: newAttachmentsToDelete });
}
handleAttachmentsChange(newAttachments: File[]) {
this.setState({ attachments: newAttachments });
}
render() { render() {
const { id, attachmentUrls, isPowerUser, tenantSetting, handleUpdateComment, toggleEditMode } = this.props; const { id, isPowerUser, handleUpdateComment, toggleEditMode } = this.props;
const { body, isPostUpdate, attachmentsToDelete, attachments } = this.state; const { body, isPostUpdate } = this.state;
return ( return (
<div className="editCommentForm"> <div className="editCommentForm">
@@ -87,81 +69,27 @@ class CommentEditForm extends React.Component<Props, State> {
</div> </div>
</div> </div>
<div className="editCommentFormAttachments"> <div>
{ /* Attachments */ } <div>
<div className="thumbnailsContainer" style={{ display: attachmentUrls && attachmentUrls.length > 0 ? 'flex' : 'none' }}> {
{ isPowerUser &&
attachmentUrls && attachmentUrls.map((attachmentUrl, i) => ( <Switch
<div className="thumbnailContainer" key={i}> htmlId={`isPostUpdateFlagComment${id}`}
<div className={`thumbnail${attachmentsToDelete.includes(i) ? ' thumbnailToDelete' : ''}`}> onClick={e => this.handleCommentIsPostUpdateChange(!isPostUpdate)}
<div className="thumbnailInner"> checked={isPostUpdate || false}
<img label={I18n.t('post.new_comment.is_post_update')}
src={attachmentUrl} />
className="thumbnailImage" }
/>
</div>
</div>
{
attachmentsToDelete.includes(i) ?
<ActionLink
onClick={() => this.handleAttachmentsToDeleteChange(attachmentsToDelete.filter(index => index !== i))}
icon={<CancelIcon />}
customClass="removeThumbnail"
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => this.handleAttachmentsToDeleteChange([...attachmentsToDelete, i])}
icon={<DeleteIcon />}
customClass="removeThumbnail"
>
{I18n.t('common.buttons.delete')}
</ActionLink>
}
</div>
))
}
</div> </div>
{ /* Attachments dropzone */ } <div className="editCommentFormActions">
{ <ActionLink onClick={toggleEditMode} icon={<CancelIcon />}>
tenantSetting.allow_attachment_upload && {I18n.t('common.buttons.cancel')}
<div className="form-group"> </ActionLink>
<Dropzone &nbsp;
files={attachments} <Button onClick={() => handleUpdateComment(body, isPostUpdate)}>
setFiles={this.handleAttachmentsChange} {I18n.t('common.buttons.update')}
maxSizeKB={2048} </Button>
maxFiles={5}
customStyle={{ minHeight: '60px', marginTop: '16px' }}
/>
</div>
}
<div className="editCommentFormFooter">
{ /* Is post update */ }
<div className="editCommentFormPostUpdate">
{
isPowerUser &&
<Switch
htmlId={`isPostUpdateFlagComment${id}`}
onClick={e => this.handleCommentIsPostUpdateChange(!isPostUpdate)}
checked={isPostUpdate || false}
label={I18n.t('post.new_comment.is_post_update')}
/>
}
</div>
<div className="editCommentFormActions">
<ActionLink onClick={toggleEditMode} icon={<CancelIcon />}>
{I18n.t('common.buttons.cancel')}
</ActionLink>
&nbsp;
<Button onClick={() => handleUpdateComment(body, isPostUpdate, attachmentsToDelete, attachments)}>
{I18n.t('common.buttons.update')}
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -3,7 +3,7 @@ import I18n from 'i18n-js';
import Separator from '../common/Separator'; import Separator from '../common/Separator';
import { MutedText } from '../common/CustomTexts'; import { MutedText } from '../common/CustomTexts';
import friendlyDate, { fromRailsStringToJavascriptDate } from '../../helpers/datetime'; import friendlyDate from '../../helpers/datetime';
import { ReplyFormState } from '../../reducers/replyFormReducer'; import { ReplyFormState } from '../../reducers/replyFormReducer';
import ActionLink from '../common/ActionLink'; import ActionLink from '../common/ActionLink';
import { CancelIcon, DeleteIcon, EditIcon, ReplyIcon } from '../common/Icons'; import { CancelIcon, DeleteIcon, EditIcon, ReplyIcon } from '../common/Icons';
@@ -71,7 +71,7 @@ const CommentFooter = ({
</span> </span>
{ {
fromRailsStringToJavascriptDate(updatedAt).getTime() - fromRailsStringToJavascriptDate(createdAt).getTime() > 10000 && createdAt !== updatedAt &&
<> <>
<Separator /> <Separator />
<MutedText>{ I18n.t('common.edited').toLowerCase() }</MutedText> <MutedText>{ I18n.t('common.edited').toLowerCase() }</MutedText>

View File

@@ -4,7 +4,6 @@ import Comment from './Comment';
import IComment from '../../interfaces/IComment'; import IComment from '../../interfaces/IComment';
import { ReplyFormState } from '../../reducers/replyFormReducer'; import { ReplyFormState } from '../../reducers/replyFormReducer';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props { interface Props {
comments: Array<IComment>; comments: Array<IComment>;
@@ -15,15 +14,13 @@ interface Props {
toggleCommentReply(commentId: number): void; toggleCommentReply(commentId: number): void;
setCommentReplyBody(commentId: number, body: string): void; setCommentReplyBody(commentId: number, body: string): void;
handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean, attachments: File[], onSuccess: Function): void; handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean): void;
handleUpdateComment(commentId: number, body: string, isPostUpdate: boolean, attachmentsToDelete: number[], attachments: File[], onSuccess: Function): void; handleUpdateComment(commentId: number, body: string, isPostUpdate: boolean, onSuccess: Function): void;
handleDeleteComment(id: number): void; handleDeleteComment(id: number): void;
tenantSetting: ITenantSetting;
isLoggedIn: boolean; isLoggedIn: boolean;
isPowerUser: boolean; isPowerUser: boolean;
userEmail: string; userEmail: string;
userAvatar?: string;
} }
const CommentList = ({ const CommentList = ({
@@ -38,11 +35,9 @@ const CommentList = ({
handleUpdateComment, handleUpdateComment,
handleDeleteComment, handleDeleteComment,
tenantSetting,
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
userEmail, userEmail,
userAvatar,
}: Props) => ( }: Props) => (
<> <>
{comments.map((comment, i) => { {comments.map((comment, i) => {
@@ -64,11 +59,9 @@ const CommentList = ({
{...comment} {...comment}
tenantSetting={tenantSetting}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
currentUserEmail={userEmail} currentUserEmail={userEmail}
currentUserAvatar={userAvatar}
/> />
<CommentList <CommentList
@@ -84,11 +77,9 @@ const CommentList = ({
handleUpdateComment={handleUpdateComment} handleUpdateComment={handleUpdateComment}
handleDeleteComment={handleDeleteComment} handleDeleteComment={handleDeleteComment}
tenantSetting={tenantSetting}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
userEmail={userEmail} userEmail={userEmail}
userAvatar={userAvatar}
/> />
</div> </div>
); );

View File

@@ -9,15 +9,12 @@ import { DangerText, MutedText } from '../common/CustomTexts';
import IComment from '../../interfaces/IComment'; import IComment from '../../interfaces/IComment';
import { ReplyFormState } from '../../reducers/replyFormReducer'; import { ReplyFormState } from '../../reducers/replyFormReducer';
import Separator from '../common/Separator'; import Separator from '../common/Separator';
import ITenantSetting from '../../interfaces/ITenantSetting';
interface Props { interface Props {
postId: number; postId: number;
tenantSetting: ITenantSetting;
isLoggedIn: boolean; isLoggedIn: boolean;
isPowerUser: boolean; isPowerUser: boolean;
userEmail: string; userEmail: string;
userAvatar?: string;
authenticityToken: string; authenticityToken: string;
comments: Array<IComment>; comments: Array<IComment>;
@@ -35,8 +32,6 @@ interface Props {
body: string, body: string,
parentId: number, parentId: number,
isPostUpdate: boolean, isPostUpdate: boolean,
attachments: File[],
onSuccess: Function,
authenticityToken: string, authenticityToken: string,
): void; ): void;
updateComment( updateComment(
@@ -44,8 +39,6 @@ interface Props {
commentId: number, commentId: number,
body: string, body: string,
isPostUpdate: boolean, isPostUpdate: boolean,
attachmentsToDelete: number[],
attachments: File[],
onSuccess: Function, onSuccess: Function,
authenticityToken: string, authenticityToken: string,
): void; ): void;
@@ -61,26 +54,22 @@ class CommentsP extends React.Component<Props> {
this.props.requestComments(this.props.postId); this.props.requestComments(this.props.postId);
} }
_handleSubmitComment = (body: string, parentId: number, isPostUpdate: boolean, attachments: File[], onSuccess: Function) => { _handleSubmitComment = (body: string, parentId: number, isPostUpdate: boolean) => {
this.props.submitComment( this.props.submitComment(
this.props.postId, this.props.postId,
body, body,
parentId, parentId,
isPostUpdate, isPostUpdate,
attachments,
onSuccess,
this.props.authenticityToken, this.props.authenticityToken,
); );
} }
_handleUpdateComment = (commentId: number, body: string, isPostUpdate: boolean, attachmentsToDelete: number[], attachments: File[], onSuccess: Function) => { _handleUpdateComment = (commentId: number, body: string, isPostUpdate: boolean, onSuccess: Function) => {
this.props.updateComment( this.props.updateComment(
this.props.postId, this.props.postId,
commentId, commentId,
body, body,
isPostUpdate, isPostUpdate,
attachmentsToDelete,
attachments,
onSuccess, onSuccess,
this.props.authenticityToken, this.props.authenticityToken,
); );
@@ -96,11 +85,9 @@ class CommentsP extends React.Component<Props> {
render() { render() {
const { const {
tenantSetting,
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
userEmail, userEmail,
userAvatar,
comments, comments,
replyForms, replyForms,
@@ -130,11 +117,9 @@ class CommentsP extends React.Component<Props> {
handlePostUpdateFlag={toggleCommentIsPostUpdateFlag} handlePostUpdateFlag={toggleCommentIsPostUpdateFlag}
handleSubmit={this._handleSubmitComment} handleSubmit={this._handleSubmitComment}
allowAttachmentUpload={tenantSetting.allow_attachment_upload}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
userEmail={userEmail} userEmail={userEmail}
userAvatar={userAvatar}
/> />
<div className="commentsTitle"> <div className="commentsTitle">
@@ -157,11 +142,9 @@ class CommentsP extends React.Component<Props> {
handleDeleteComment={this._handleDeleteComment} handleDeleteComment={this._handleDeleteComment}
parentId={null} parentId={null}
level={1} level={1}
tenantSetting={tenantSetting}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
userEmail={userEmail} userEmail={userEmail}
userAvatar={userAvatar}
/> />
</div> </div>
); );

View File

@@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import NewCommentUpdateSection from './NewCommentUpdateSection'; import NewCommentUpdateSection from './NewCommentUpdateSection';
@@ -6,10 +7,7 @@ import NewCommentUpdateSection from './NewCommentUpdateSection';
import Button from '../common/Button'; import Button from '../common/Button';
import Spinner from '../common/Spinner'; import Spinner from '../common/Spinner';
import { DangerText } from '../common/CustomTexts'; import { DangerText } from '../common/CustomTexts';
import { AttachIcon, CancelIcon, MarkdownIcon } from '../common/Icons'; import { MarkdownIcon } from '../common/Icons';
import Avatar from '../common/Avatar';
import ActionLink from '../common/ActionLink';
import Dropzone from '../common/Dropzone';
interface Props { interface Props {
body: string; body: string;
@@ -22,16 +20,12 @@ interface Props {
handleSubmit( handleSubmit(
body: string, body: string,
parentId: number, parentId: number,
isPostUpdate: boolean, isPostUpdate: boolean
attachments: File[],
onSuccess: Function,
): void; ): void;
allowAttachmentUpload: boolean;
isLoggedIn: boolean; isLoggedIn: boolean;
isPowerUser: boolean; isPowerUser: boolean;
userEmail: string; userEmail: string;
userAvatar?: string;
} }
const NewComment = ({ const NewComment = ({
@@ -44,106 +38,56 @@ const NewComment = ({
handlePostUpdateFlag, handlePostUpdateFlag,
handleSubmit, handleSubmit,
allowAttachmentUpload,
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
userEmail, userEmail,
userAvatar, }: Props) => (
}: Props) => { <>
<div className="newCommentForm">
const [isAttachingFiles, setIsAttachingFiles] = React.useState(false); {
const [attachments, setAttachments] = React.useState<File[]>([]); isLoggedIn ?
<>
return ( <div className="commentBodyForm">
<> <Gravatar email={userEmail} size={48} className="currentUserAvatar" />
<div className="newCommentForm">
{ <div style={{width: '100%', marginRight: '8px'}}>
isLoggedIn ? <textarea
<> value={body}
<div className="newCommentBodyForm"> onChange={handleChange}
<Avatar avatarUrl={userAvatar} email={userEmail} size={48} customClass="currentUserAvatar" /> autoFocus={parentId != null}
placeholder={I18n.t('post.new_comment.body_placeholder')}
<div style={{width: '100%', marginRight: '8px'}}> className="commentForm"
<textarea />
value={body} <div style={{position: 'relative', width: 0, height: 0}}>
onChange={handleChange} <MarkdownIcon style={{position: 'absolute', left: '6px', top: '-28px'}} />
autoFocus={parentId != null}
placeholder={I18n.t('post.new_comment.body_placeholder')}
className="commentForm"
/>
<div style={{position: 'relative', width: 0, height: 0}}>
<MarkdownIcon style={{position: 'absolute', left: '6px', top: '-28px'}} />
</div>
</div> </div>
<Button
onClick={() => {
handleSubmit(
body,
parentId,
postUpdateFlagValue,
attachments,
() => { setIsAttachingFiles(false); setAttachments([]); }
);
}}
className="submitCommentButton">
{ isSubmitting ? <Spinner color="light" /> : I18n.t('post.new_comment.submit_button') }
</Button>
</div> </div>
<div className="newCommentFooter">
{ /* Attach files */ } <Button
<div className={`attachFilesSection${allowAttachmentUpload ? '' : ' attachFilesSectionHidden'}`}> onClick={() => handleSubmit(body, parentId, postUpdateFlagValue)}
{ className="submitCommentButton">
isAttachingFiles ? { isSubmitting ? <Spinner color="light" /> : I18n.t('post.new_comment.submit_button') }
<ActionLink </Button>
icon={<CancelIcon />} </div>
onClick={() => { setIsAttachingFiles(false); setAttachments([]); }} {
customClass='cancelAttachFilesNewComment' isPowerUser && parentId == null ?
> <NewCommentUpdateSection
{I18n.t('common.buttons.cancel')} postUpdateFlagValue={postUpdateFlagValue}
</ActionLink> handlePostUpdateFlag={handlePostUpdateFlag}
: />
<ActionLink :
icon={<AttachIcon />} null
onClick={() => setIsAttachingFiles(true)} }
customClass='showAttachFilesNewComment' </>
> :
{I18n.t('common.buttons.attach')} <a href="/users/sign_in" className="loginInfo">
</ActionLink> {I18n.t('post.new_comment.not_logged_in')}
} </a>
{ }
isAttachingFiles && </div>
<Dropzone
files={attachments}
setFiles={setAttachments}
maxSizeKB={2048}
maxFiles={5}
customStyle={{ minHeight: '60px', marginTop: '16px' }}
/>
}
</div>
{ /* Post update flag */ } { error ? <DangerText>{error}</DangerText> : null }
{ </>
isPowerUser && parentId == null && );
<NewCommentUpdateSection
postUpdateFlagValue={postUpdateFlagValue}
handlePostUpdateFlag={handlePostUpdateFlag}
allowAttachmentUpload={allowAttachmentUpload}
/>
}
</div>
</>
:
<a href="/users/sign_in" className="loginInfo">
{I18n.t('post.new_comment.not_logged_in')}
</a>
}
</div>
{ error ? <DangerText>{error}</DangerText> : null }
</>
);
};
export default NewComment; export default NewComment;

View File

@@ -7,15 +7,13 @@ import Switch from '../common/Switch';
interface Props { interface Props {
postUpdateFlagValue: boolean; postUpdateFlagValue: boolean;
handlePostUpdateFlag(): void; handlePostUpdateFlag(): void;
allowAttachmentUpload?: boolean;
} }
const NewCommentUpdateSection = ({ const NewCommentUpdateSection = ({
postUpdateFlagValue, postUpdateFlagValue,
handlePostUpdateFlag, handlePostUpdateFlag,
allowAttachmentUpload = true,
}: Props) => ( }: Props) => (
<div className={`commentIsUpdateForm${allowAttachmentUpload ? ' commentIsUpdateFormWithAttachment' : ' commentIsUpdateFormWithoutAttachment'}`}> <div className="commentIsUpdateForm">
<Switch <Switch
htmlId="isPostUpdateFlag" htmlId="isPostUpdateFlag"
onClick={handlePostUpdateFlag} onClick={handlePostUpdateFlag}

View File

@@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import Gravatar from 'react-gravatar';
import IPost, { PostApprovalStatus } from '../../../interfaces/IPost'; import IPost, { PostApprovalStatus } from '../../../interfaces/IPost';
import { AnonymousIcon, ApproveIcon, RejectIcon } from '../../common/Icons'; import { AnonymousIcon, ApproveIcon, RejectIcon } from '../../common/Icons';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import ActionLink from '../../common/ActionLink'; import ActionLink from '../../common/ActionLink';
import Avatar from '../../common/Avatar';
interface Props { interface Props {
post: IPost; post: IPost;
@@ -25,7 +25,7 @@ const FeedbackListItem = ({ post, onUpdatePostApprovalStatus, hideRejectButton }
<div className="feedbackListItemIcon"> <div className="feedbackListItemIcon">
{ {
post.userId ? post.userId ?
<Avatar avatarUrl={post.userAvatar} email={post.userEmail} size={42} customClass="userAvatar" /> <Gravatar email={post.userEmail} size={42} title={post.userEmail} className="gravatar userGravatar" />
: :
<AnonymousIcon size={42} /> <AnonymousIcon size={42} />
} }

View File

@@ -1,4 +1,5 @@
import * as React from "react"; import * as React from "react";
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import IUser, { UserRoles, USER_ROLE_ADMIN, USER_ROLE_MODERATOR, USER_ROLE_OWNER, USER_ROLE_USER, USER_STATUS_ACTIVE, USER_STATUS_BLOCKED, USER_STATUS_DELETED } from "../../../interfaces/IUser"; import IUser, { UserRoles, USER_ROLE_ADMIN, USER_ROLE_MODERATOR, USER_ROLE_OWNER, USER_ROLE_USER, USER_STATUS_ACTIVE, USER_STATUS_BLOCKED, USER_STATUS_DELETED } from "../../../interfaces/IUser";
@@ -7,7 +8,6 @@ import UserForm from "./UserForm";
import { MutedText } from "../../common/CustomTexts"; import { MutedText } from "../../common/CustomTexts";
import { BlockIcon, CancelIcon, EditIcon, UnblockIcon } from "../../common/Icons"; import { BlockIcon, CancelIcon, EditIcon, UnblockIcon } from "../../common/Icons";
import ActionLink from "../../common/ActionLink"; import ActionLink from "../../common/ActionLink";
import Avatar from "../../common/Avatar";
interface Props { interface Props {
user: IUser; user: IUser;
@@ -94,7 +94,7 @@ class UserEditable extends React.Component<Props, State> {
editMode === false ? editMode === false ?
<> <>
<div className="userInfo"> <div className="userInfo">
<Avatar avatarUrl={user.avatarUrl} email={user.email} size={42} customClass="userAvatar" /> <Gravatar email={user.email} size={42} className="gravatar userGravatar" />
<div className="userFullNameRoleStatus"> <div className="userFullNameRoleStatus">
<span className="userFullName">{ user.fullName }</span> <span className="userFullName">{ user.fullName }</span>

View File

@@ -1,10 +1,10 @@
import * as React from 'react'; import * as React from 'react';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import Button from '../../common/Button'; import Button from '../../common/Button';
import IUser, { UserRoles, USER_ROLE_ADMIN, USER_ROLE_MODERATOR, USER_ROLE_USER } from '../../../interfaces/IUser'; import IUser, { UserRoles, USER_ROLE_ADMIN, USER_ROLE_MODERATOR, USER_ROLE_USER } from '../../../interfaces/IUser';
import { getLabel } from '../../../helpers/formUtils'; import { getLabel } from '../../../helpers/formUtils';
import Avatar from '../../common/Avatar';
interface Props { interface Props {
user: IUser; user: IUser;
@@ -44,7 +44,7 @@ class UserForm extends React.Component<Props, State> {
return ( return (
<div className="userForm"> <div className="userForm">
<Avatar avatarUrl={user.avatarUrl} email={user.email} size={42} customClass="userAvatar" /> <Gravatar email={user.email} size={42} className="gravatar userGravatar" />
<div className="userFullNameRoleForm"> <div className="userFullNameRoleForm">
<span className="userFullName">{ user.fullName }</span> <span className="userFullName">{ user.fullName }</span>

View File

@@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import Gravatar from 'react-gravatar';
import ILike from '../../interfaces/ILike'; import ILike from '../../interfaces/ILike';
import Spinner from '../common/Spinner'; import Spinner from '../common/Spinner';
@@ -8,7 +9,6 @@ import {
DangerText, DangerText,
CenteredMutedText CenteredMutedText
} from '../common/CustomTexts'; } from '../common/CustomTexts';
import Avatar from '../common/Avatar';
interface Props { interface Props {
likes: Array<ILike>; likes: Array<ILike>;
@@ -26,8 +26,7 @@ const LikeList = ({ likes, areLoading, error}: Props) => (
{ {
likes.map((like, i) => ( likes.map((like, i) => (
<div className="likeListItem" key={i}> <div className="likeListItem" key={i}>
<Avatar avatarUrl={like.userAvatar} email={like.email} size={32} /> <Gravatar email={like.email} size={32} className="gravatar" />
<div className="likeListItemUserInfo"> <div className="likeListItemUserInfo">
<span className="likeListItemName" title={like.fullName}>{like.fullName}</span> <span className="likeListItemName" title={like.fullName}>{like.fullName}</span>
<span className="likeListItemEmail" title={like.email}>{like.email}</span> <span className="likeListItemEmail" title={like.email}>{like.email}</span>

View File

@@ -1,20 +0,0 @@
import React from 'react';
interface Props {
attachmentUrls?: string[];
}
const PostAttachments = ({ attachmentUrls = [] }: Props) => (
attachmentUrls.length > 0 &&
<div className="postAttachments">
{
attachmentUrls.map((url, index) => (
<a key={index} href={url} target="_blank">
<img src={url} className="postAttachment" />
</a>
))
}
</div>
);
export default PostAttachments;

View File

@@ -9,17 +9,13 @@ import IBoard from '../../interfaces/IBoard';
import Button from '../common/Button'; import Button from '../common/Button';
import Spinner from '../common/Spinner'; import Spinner from '../common/Spinner';
import ActionLink from '../common/ActionLink'; import ActionLink from '../common/ActionLink';
import { CancelIcon, DeleteIcon } from '../common/Icons'; import { CancelIcon } from '../common/Icons';
import ITenantSetting from '../../interfaces/ITenantSetting';
import Dropzone from '../common/Dropzone';
import { DangerText } from '../common/CustomTexts';
interface Props { interface Props {
title: string; title: string;
description?: string; description?: string;
boardId: number; boardId: number;
postStatusId?: number; postStatusId?: number;
attachmentUrls?: string[];
isUpdating: boolean; isUpdating: boolean;
error: string; error: string;
@@ -29,7 +25,6 @@ interface Props {
handleChangeBoard(boardId: number): void; handleChangeBoard(boardId: number): void;
handleChangePostStatus(postStatusId: number): void; handleChangePostStatus(postStatusId: number): void;
tenantSetting: ITenantSetting;
isPowerUser: boolean; isPowerUser: boolean;
boards: Array<IBoard>; boards: Array<IBoard>;
postStatuses: Array<IPostStatus>; postStatuses: Array<IPostStatus>;
@@ -40,8 +35,6 @@ interface Props {
description: string, description: string,
boardId: number, boardId: number,
postStatusId: number, postStatusId: number,
attachmentsToDelete: number[],
attachments: File[],
): void; ): void;
} }
@@ -50,7 +43,6 @@ const PostEditForm = ({
description, description,
boardId, boardId,
postStatusId, postStatusId,
attachmentUrls,
isUpdating, isUpdating,
error, error,
@@ -60,122 +52,59 @@ const PostEditForm = ({
handleChangeBoard, handleChangeBoard,
handleChangePostStatus, handleChangePostStatus,
tenantSetting,
isPowerUser, isPowerUser,
boards, boards,
postStatuses, postStatuses,
toggleEditMode, toggleEditMode,
handleUpdatePost, handleUpdatePost,
}: Props) => { }: Props) => (
const [attachmentsToDelete, setAttachmentsToDelete] = React.useState<number[]>([]); <div className="postEditForm">
const [attachments, setAttachments] = React.useState<File[]>([]); <div className="postHeader">
<input
React.useEffect(() => { type="text"
setAttachmentsToDelete([]); value={title}
}, [attachmentUrls]); onChange={e => handleChangeTitle(e.target.value)}
autoFocus
return ( className="postTitle form-control"
<div className="postEditForm">
<div className="postHeader">
<input
type="text"
value={title}
onChange={e => handleChangeTitle(e.target.value)}
autoFocus
className="postTitle form-control"
/>
</div>
{
isPowerUser ?
<div className="postSettings">
<PostBoardSelect
boards={boards}
selectedBoardId={boardId}
handleChange={newBoardId => handleChangeBoard(newBoardId)}
/>
<PostStatusSelect
postStatuses={postStatuses}
selectedPostStatusId={postStatusId}
handleChange={newPostStatusId => handleChangePostStatus(newPostStatusId)}
/>
</div>
:
null
}
<textarea
value={description}
onChange={e => handleChangeDescription(e.target.value)}
rows={5}
className="postDescription form-control"
/> />
{ /* Attachments */ }
<div className="thumbnailsContainer" style={{ display: attachmentUrls && attachmentUrls.length > 0 ? 'flex' : 'none' }}>
{
attachmentUrls && attachmentUrls.map((attachmentUrl, i) => (
<div className="thumbnailContainer" key={i}>
<div className={`thumbnail${attachmentsToDelete.includes(i) ? ' thumbnailToDelete' : ''}`}>
<div className="thumbnailInner">
<img
src={attachmentUrl}
className="thumbnailImage"
/>
</div>
</div>
{
attachmentsToDelete.includes(i) ?
<ActionLink
onClick={() => setAttachmentsToDelete(attachmentsToDelete.filter(index => index !== i))}
icon={<CancelIcon />}
customClass="removeThumbnail"
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => setAttachmentsToDelete([...attachmentsToDelete, i])}
icon={<DeleteIcon />}
customClass="removeThumbnail"
>
{I18n.t('common.buttons.delete')}
</ActionLink>
}
</div>
))
}
</div>
{ /* Attachments dropzone */ }
{
tenantSetting.allow_attachment_upload &&
<div className="form-group">
<Dropzone
files={attachments}
setFiles={setAttachments}
maxSizeKB={2048}
maxFiles={5}
customStyle={{ minHeight: '60px', marginTop: '16px' }}
/>
</div>
}
<div className="postEditFormButtons">
<ActionLink onClick={toggleEditMode} icon={<CancelIcon />}>
{I18n.t('common.buttons.cancel')}
</ActionLink>
&nbsp;
<Button onClick={() => handleUpdatePost(title, description, boardId, postStatusId, attachmentsToDelete, attachments)}>
{ isUpdating ? <Spinner /> : I18n.t('common.buttons.update') }
</Button>
</div>
{ error && <DangerText>{error}</DangerText> }
</div> </div>
);
}; {
isPowerUser ?
<div className="postSettings">
<PostBoardSelect
boards={boards}
selectedBoardId={boardId}
handleChange={newBoardId => handleChangeBoard(newBoardId)}
/>
<PostStatusSelect
postStatuses={postStatuses}
selectedPostStatusId={postStatusId}
handleChange={newPostStatusId => handleChangePostStatus(newPostStatusId)}
/>
</div>
:
null
}
<textarea
value={description}
onChange={e => handleChangeDescription(e.target.value)}
rows={5}
className="postDescription form-control"
/>
<div className="postEditFormButtons">
<ActionLink onClick={toggleEditMode} icon={<CancelIcon />}>
{I18n.t('common.buttons.cancel')}
</ActionLink>
&nbsp;
<Button onClick={() => handleUpdatePost(title, description, boardId, postStatusId)}>
{ isUpdating ? <Spinner /> : I18n.t('common.buttons.update') }
</Button>
</div>
</div>
);
export default PostEditForm; export default PostEditForm;

View File

@@ -1,11 +1,12 @@
import * as React from 'react'; import * as React from 'react';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import { MutedText } from '../common/CustomTexts';
import friendlyDate from '../../helpers/datetime'; import friendlyDate from '../../helpers/datetime';
import Separator from '../common/Separator'; import Separator from '../common/Separator';
import ActionLink from '../common/ActionLink'; import ActionLink from '../common/ActionLink';
import { DeleteIcon, EditIcon } from '../common/Icons'; import { DeleteIcon, EditIcon } from '../common/Icons';
import Avatar from '../common/Avatar';
interface Props { interface Props {
createdAt: string; createdAt: string;
@@ -14,7 +15,6 @@ interface Props {
isPowerUser: boolean; isPowerUser: boolean;
authorEmail: string; authorEmail: string;
authorFullName: string; authorFullName: string;
authorAvatar?: string;
currentUserEmail: string; currentUserEmail: string;
} }
@@ -25,7 +25,6 @@ const PostFooter = ({
isPowerUser, isPowerUser,
authorEmail, authorEmail,
authorFullName, authorFullName,
authorAvatar,
currentUserEmail, currentUserEmail,
}: Props) => ( }: Props) => (
<div className="postFooter"> <div className="postFooter">
@@ -34,7 +33,7 @@ const PostFooter = ({
authorEmail ? authorEmail ?
<> <>
<span>{I18n.t('post.published_by').toLowerCase()} &nbsp;</span> <span>{I18n.t('post.published_by').toLowerCase()} &nbsp;</span>
<Avatar avatarUrl={authorAvatar} email={authorEmail} size={24} customClass="postAuthorAvatar" /> &nbsp; <Gravatar email={authorEmail} size={24} className="postAuthorAvatar" /> &nbsp;
<span>{authorFullName}</span> <span>{authorFullName}</span>
</> </>
: :

View File

@@ -9,7 +9,6 @@ import ITenantSetting from '../../interfaces/ITenantSetting';
import PostUpdateList from './PostUpdateList'; import PostUpdateList from './PostUpdateList';
import PostEditForm from './PostEditForm'; import PostEditForm from './PostEditForm';
import PostAttachments from './PostAttachments';
import PostFooter from './PostFooter'; import PostFooter from './PostFooter';
import LikeList from './LikeList'; import LikeList from './LikeList';
import ActionBox from './ActionBox'; import ActionBox from './ActionBox';
@@ -48,7 +47,6 @@ interface Props {
isPowerUser: boolean; isPowerUser: boolean;
currentUserFullName: string; currentUserFullName: string;
currentUserEmail: string; currentUserEmail: string;
currentUserAvatar?: string;
tenantSetting: ITenantSetting; tenantSetting: ITenantSetting;
authenticityToken: string; authenticityToken: string;
@@ -59,8 +57,6 @@ interface Props {
description: string, description: string,
boardId: number, boardId: number,
postStatusId: number, postStatusId: number,
attachmentsToDelete: number[],
attachments: File[],
authenticityToken: string, authenticityToken: string,
): Promise<any>; ): Promise<any>;
@@ -116,14 +112,7 @@ class PostP extends React.Component<Props, State> {
this.props.requestPostStatusChanges(postId); this.props.requestPostStatusChanges(postId);
} }
_handleUpdatePost( _handleUpdatePost(title: string, description: string, boardId: number, postStatusId: number) {
title: string,
description: string,
boardId: number,
postStatusId: number,
attachmentsToDelete: number[],
attachments: File[],
) {
const { const {
postId, postId,
post, post,
@@ -143,8 +132,6 @@ class PostP extends React.Component<Props, State> {
description, description,
boardId, boardId,
postStatusId, postStatusId,
attachmentsToDelete,
attachments,
authenticityToken, authenticityToken,
).then(res => { ).then(res => {
if (res?.status !== HttpStatus.OK) return; if (res?.status !== HttpStatus.OK) return;
@@ -184,7 +171,6 @@ class PostP extends React.Component<Props, State> {
isLoggedIn, isLoggedIn,
isPowerUser, isPowerUser,
currentUserEmail, currentUserEmail,
currentUserAvatar,
tenantSetting, tenantSetting,
authenticityToken, authenticityToken,
@@ -246,14 +232,12 @@ class PostP extends React.Component<Props, State> {
editMode ? editMode ?
<PostEditForm <PostEditForm
{...editForm} {...editForm}
attachmentUrls={postToShow.attachmentUrls}
handleChangeTitle={handleChangeEditFormTitle} handleChangeTitle={handleChangeEditFormTitle}
handleChangeDescription={handleChangeEditFormDescription} handleChangeDescription={handleChangeEditFormDescription}
handleChangeBoard={handleChangeEditFormBoard} handleChangeBoard={handleChangeEditFormBoard}
handleChangePostStatus={handleChangeEditFormPostStatus} handleChangePostStatus={handleChangeEditFormPostStatus}
tenantSetting={tenantSetting}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
boards={boards} boards={boards}
postStatuses={postStatuses} postStatuses={postStatuses}
@@ -308,10 +292,6 @@ class PostP extends React.Component<Props, State> {
{postToShow.description} {postToShow.description}
</ReactMarkdown> </ReactMarkdown>
<PostAttachments
attachmentUrls={postToShow?.attachmentUrls}
/>
<PostFooter <PostFooter
createdAt={postToShow.createdAt} createdAt={postToShow.createdAt}
handleDeletePost={this._handleDeletePost} handleDeletePost={this._handleDeletePost}
@@ -320,7 +300,6 @@ class PostP extends React.Component<Props, State> {
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
authorEmail={postToShow.userEmail} authorEmail={postToShow.userEmail}
authorFullName={postToShow.userFullName} authorFullName={postToShow.userFullName}
authorAvatar={originPost.authorAvatar}
currentUserEmail={currentUserEmail} currentUserEmail={currentUserEmail}
/> />
</> </>
@@ -328,11 +307,9 @@ class PostP extends React.Component<Props, State> {
<Comments <Comments
postId={this.props.postId} postId={this.props.postId}
tenantSetting={tenantSetting}
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
userEmail={currentUserEmail} userEmail={currentUserEmail}
userAvatar={currentUserAvatar}
authenticityToken={authenticityToken} authenticityToken={authenticityToken}
/> />
</div> </div>

View File

@@ -1,8 +1,9 @@
import * as React from 'react'; import * as React from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import { DangerText, CenteredMutedText } from '../common/CustomTexts'; import { DangerText, CenteredMutedText, MutedText } from '../common/CustomTexts';
import Spinner from '../common/Spinner'; import Spinner from '../common/Spinner';
import IComment from '../../interfaces/IComment'; import IComment from '../../interfaces/IComment';
@@ -12,7 +13,6 @@ import IPostStatus from '../../interfaces/IPostStatus';
import friendlyDate from '../../helpers/datetime'; import friendlyDate from '../../helpers/datetime';
import PostStatusLabel from '../common/PostStatusLabel'; import PostStatusLabel from '../common/PostStatusLabel';
import SidebarBox from '../common/SidebarBox'; import SidebarBox from '../common/SidebarBox';
import Avatar from '../common/Avatar';
interface Props { interface Props {
postUpdates: Array<IComment | IPostStatusChange>; postUpdates: Array<IComment | IPostStatusChange>;
@@ -42,8 +42,7 @@ const PostUpdateList = ({
postUpdates.map((postUpdate, i) => ( postUpdates.map((postUpdate, i) => (
<div className="postUpdateListItem" key={i}> <div className="postUpdateListItem" key={i}>
<div className="postUpdateListItemHeader"> <div className="postUpdateListItemHeader">
<Avatar avatarUrl={postUpdate.userAvatar} email={postUpdate.userEmail} size={28} /> <Gravatar email={postUpdate.userEmail} size={28} className="gravatar" />
<span>{postUpdate.userFullName}</span> <span>{postUpdate.userFullName}</span>
</div> </div>

View File

@@ -21,7 +21,6 @@ interface Props {
isPowerUser: boolean; isPowerUser: boolean;
currentUserFullName: string; currentUserFullName: string;
currentUserEmail: string; currentUserEmail: string;
currentUserAvatar?: string;
tenantSetting: ITenantSetting; tenantSetting: ITenantSetting;
authenticityToken: string; authenticityToken: string;
} }
@@ -45,7 +44,6 @@ class PostRoot extends React.Component<Props> {
isPowerUser, isPowerUser,
currentUserFullName, currentUserFullName,
currentUserEmail, currentUserEmail,
currentUserAvatar,
tenantSetting, tenantSetting,
authenticityToken authenticityToken
} = this.props; } = this.props;
@@ -62,7 +60,6 @@ class PostRoot extends React.Component<Props> {
isPowerUser={isPowerUser} isPowerUser={isPowerUser}
currentUserFullName={currentUserFullName} currentUserFullName={currentUserFullName}
currentUserEmail={currentUserEmail} currentUserEmail={currentUserEmail}
currentUserAvatar={currentUserAvatar}
tenantSetting={tenantSetting} tenantSetting={tenantSetting}
authenticityToken={authenticityToken} authenticityToken={authenticityToken}
/> />

View File

@@ -78,7 +78,7 @@ const AppearanceSiteSettingsP = ({
<p style={{textAlign: 'left'}}> <p style={{textAlign: 'left'}}>
<ActionLink <ActionLink
onClick={() => window.open('https://docs.astuto.io/appearance-customization/', '_blank')} onClick={() => window.open('https://github.com/astuto/astuto-docs/blob/main/docs/appearance.md', '_blank')}
icon={<LearnMoreIcon />} icon={<LearnMoreIcon />}
> >
{I18n.t('site_settings.appearance.learn_more')} {I18n.t('site_settings.appearance.learn_more')}

View File

@@ -8,8 +8,8 @@ import { DangerText } from '../../common/CustomTexts';
import { IOAuth } from '../../../interfaces/IOAuth'; import { IOAuth } from '../../../interfaces/IOAuth';
interface Props { interface Props {
handleSubmitOAuth(oAuth: IOAuth, oAuthLogo: File): void; handleSubmitOAuth(oAuth: IOAuth): void;
handleUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, shouldDeleteLogo: boolean): void; handleUpdateOAuth(id: number, form: ISiteSettingsOAuthForm): void;
isSubmitting: boolean; isSubmitting: boolean;
submitError: string; submitError: string;
selectedOAuth: IOAuth; selectedOAuth: IOAuth;

View File

@@ -16,8 +16,8 @@ interface Props {
oAuths: OAuthsState; oAuths: OAuthsState;
requestOAuths(): void; requestOAuths(): void;
onSubmitOAuth(oAuth: IOAuth, oAuthLogo: File, authenticityToken: string): Promise<any>; onSubmitOAuth(oAuth: IOAuth, authenticityToken: string): Promise<any>;
onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, shouldDeleteLogo: boolean, authenticityToken: string): Promise<any>; onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, authenticityToken: string): Promise<any>;
onToggleEnabledOAuth(id: number, isEnabled: boolean, authenticityToken: string): void; onToggleEnabledOAuth(id: number, isEnabled: boolean, authenticityToken: string): void;
onToggleEnabledDefaultOAuth(id: number, isEnabled: boolean, authenticityToken: string): void; onToggleEnabledDefaultOAuth(id: number, isEnabled: boolean, authenticityToken: string): void;
onDeleteOAuth(id: number, authenticityToken: string): void; onDeleteOAuth(id: number, authenticityToken: string): void;
@@ -50,14 +50,14 @@ const AuthenticationSiteSettingsP = ({
useEffect(requestOAuths, []); useEffect(requestOAuths, []);
const handleSubmitOAuth = (oAuth: IOAuth, oAuthLogo: File) => { const handleSubmitOAuth = (oAuth: IOAuth) => {
onSubmitOAuth(oAuth, oAuthLogo, authenticityToken).then(res => { onSubmitOAuth(oAuth, authenticityToken).then(res => {
if (res?.status === HttpStatus.Created) setPage('index'); if (res?.status === HttpStatus.Created) setPage('index');
}); });
}; };
const handleUpdateOAuth = (id: number, form: ISiteSettingsOAuthForm, shouldDeleteLogo: boolean) => { const handleUpdateOAuth = (id: number, form: ISiteSettingsOAuthForm) => {
onUpdateOAuth(id, form, shouldDeleteLogo, authenticityToken).then(res => { onUpdateOAuth(id, form, authenticityToken).then(res => {
if (res?.status === HttpStatus.OK) setPage('index'); if (res?.status === HttpStatus.OK) setPage('index');
}); });
}; };

View File

@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import { Controller, SubmitHandler, useForm } from 'react-hook-form'; import { SubmitHandler, useForm } from 'react-hook-form';
import { DangerText } from '../../common/CustomTexts'; import { DangerText } from '../../common/CustomTexts';
import { getLabel, getValidationMessage } from '../../../helpers/formUtils'; import { getLabel, getValidationMessage } from '../../../helpers/formUtils';
@@ -11,22 +11,20 @@ import { AuthenticationPages } from './AuthenticationSiteSettingsP';
import { useState } from 'react'; import { useState } from 'react';
import Separator from '../../common/Separator'; import Separator from '../../common/Separator';
import ActionLink from '../../common/ActionLink'; import ActionLink from '../../common/ActionLink';
import { BackIcon, CancelIcon, DeleteIcon, EditIcon } from '../../common/Icons'; import { BackIcon } from '../../common/Icons';
import Dropzone from '../../common/Dropzone';
interface Props { interface Props {
selectedOAuth: IOAuth; selectedOAuth: IOAuth;
page: AuthenticationPages; page: AuthenticationPages;
setPage: React.Dispatch<React.SetStateAction<AuthenticationPages>>; setPage: React.Dispatch<React.SetStateAction<AuthenticationPages>>;
handleSubmitOAuth(oAuth: IOAuth, oAuthLogo: File): void; handleSubmitOAuth(oAuth: IOAuth): void;
handleUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, shouldDeleteLogo: boolean): void; handleUpdateOAuth(id: number, form: ISiteSettingsOAuthForm): void;
} }
export interface ISiteSettingsOAuthForm { export interface ISiteSettingsOAuthForm {
name: string; name: string;
logo?: File; logo: string;
shouldDeleteLogo: boolean;
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
authorizeUrl: string; authorizeUrl: string;
@@ -50,14 +48,11 @@ const OAuthForm = ({
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors, isDirty }, formState: { errors, isDirty }
control,
watch,
} = useForm<ISiteSettingsOAuthForm>({ } = useForm<ISiteSettingsOAuthForm>({
defaultValues: page === 'new' ? { defaultValues: page === 'new' ? {
name: '', name: '',
logo: null, logo: '',
shouldDeleteLogo: false,
clientId: '', clientId: '',
clientSecret: '', clientSecret: '',
authorizeUrl: '', authorizeUrl: '',
@@ -68,8 +63,7 @@ const OAuthForm = ({
jsonUserNamePath: '', jsonUserNamePath: '',
} : { } : {
name: selectedOAuth.name, name: selectedOAuth.name,
logo: null, logo: selectedOAuth.logo,
shouldDeleteLogo: false,
clientId: selectedOAuth.clientId, clientId: selectedOAuth.clientId,
clientSecret: selectedOAuth.clientSecret, clientSecret: selectedOAuth.clientSecret,
authorizeUrl: selectedOAuth.authorizeUrl, authorizeUrl: selectedOAuth.authorizeUrl,
@@ -85,26 +79,16 @@ const OAuthForm = ({
const oAuth = { ...data, isEnabled: false }; const oAuth = { ...data, isEnabled: false };
if (page === 'new') { if (page === 'new') {
handleSubmitOAuth( handleSubmitOAuth(oAuth);
oAuth,
data.logo ? data.logo : null
);
} else if (page === 'edit') { } else if (page === 'edit') {
if (editClientSecret === false) { if (editClientSecret === false) {
delete oAuth.clientSecret; delete oAuth.clientSecret;
} }
handleUpdateOAuth( handleUpdateOAuth(selectedOAuth.id, oAuth as ISiteSettingsOAuthForm);
selectedOAuth.id,
oAuth as ISiteSettingsOAuthForm,
data.shouldDeleteLogo
);
} }
}; };
const shouldDeleteLogo = watch('shouldDeleteLogo');
const [showOAuthLogoDropzone, setShowOAuthLogoDropzone] = React.useState([null, undefined, ''].includes(selectedOAuth?.logoUrl));
return ( return (
<> <>
<ActionLink <ActionLink
@@ -135,81 +119,13 @@ const OAuthForm = ({
<div className="formGroup col-6"> <div className="formGroup col-6">
<label htmlFor="logo">{ getLabel('o_auth', 'logo') }</label> <label htmlFor="logo">{ getLabel('o_auth', 'logo') }</label>
<input
{ {...register('logo')}
selectedOAuth && selectedOAuth.logoUrl && placeholder='https://example.com/logo.png'
<div className={`oAuthLogoPreview${shouldDeleteLogo ? ' oAuthLogoPreviewShouldDelete' : ''}`}> id="logo"
<img src={selectedOAuth.logoUrl} alt={`${selectedOAuth.name} OAuth logo`} className="oAuthLogoPreviewImg" /> className="formControl"
</div> />
} </div>
<div className="oAuthLogoActions">
{
(selectedOAuth && selectedOAuth.logoUrl && !shouldDeleteLogo) &&
(showOAuthLogoDropzone ?
<ActionLink
onClick={() => setShowOAuthLogoDropzone(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => setShowOAuthLogoDropzone(true)}
icon={<EditIcon />}
>
{I18n.t('common.buttons.edit')}
</ActionLink>)
}
{
(selectedOAuth && selectedOAuth.logoUrl && !showOAuthLogoDropzone) &&
(shouldDeleteLogo ?
<Controller
name="shouldDeleteLogo"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
)}
/>
:
<Controller
name="shouldDeleteLogo"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(true)}
icon={<DeleteIcon />}
>
{I18n.t('common.buttons.delete')}
</ActionLink>
)}
/>
)
}
</div>
{
showOAuthLogoDropzone &&
<Controller
name="logo"
control={control}
render={({ field }) => (
<Dropzone
files={field.value ? [field.value] : []}
setFiles={files => files.length > 0 ? field.onChange(files[0]) : field.onChange(null)}
maxSizeKB={64}
maxFiles={1}
/>
)}
/>
}
</div>
</div> </div>
<h5>{ I18n.t('site_settings.authentication.form.subtitle_oauth_config') }</h5> <h5>{ I18n.t('site_settings.authentication.form.subtitle_oauth_config') }</h5>

View File

@@ -29,8 +29,8 @@ const OAuthProviderItem = ({
<li className="oAuthListItem"> <li className="oAuthListItem">
<div className="oAuthInfo"> <div className="oAuthInfo">
{ {
oAuth.logoUrl && oAuth.logoUrl.length > 0 ? oAuth.logo && oAuth.logo.length > 0 ?
<img src={oAuth.logoUrl} className="oAuthLogo" width={42} height={42} /> <img src={oAuth.logo} className="oAuthLogo" width={42} height={42} />
: :
<div className="oauthLogo" style={{width: 42, height: 42}}></div> <div className="oauthLogo" style={{width: 42, height: 42}}></div>
} }

View File

@@ -28,14 +28,14 @@ const OAuthProvidersList = ({
<> <>
<div className="oauthProvidersTitle"> <div className="oauthProvidersTitle">
<h4>{ I18n.t('site_settings.authentication.oauth_subtitle') }</h4> <h4>{ I18n.t('site_settings.authentication.oauth_subtitle') }</h4>
<Button onClick={() => { setSelectedOAuth(null); setPage('new'); }}> <Button onClick={() => setPage('new')}>
{ I18n.t('common.buttons.new') } { I18n.t('common.buttons.new') }
</Button> </Button>
</div> </div>
<p style={{textAlign: 'left'}}> <p style={{textAlign: 'left'}}>
<ActionLink <ActionLink
onClick={() => window.open('https://docs.astuto.io/oauth/oauth-configuration-basics', '_blank')} onClick={() => window.open('https://github.com/astuto/astuto-docs/blob/main/docs/oauth/oauth-configuration-basics.md', '_blank')}
icon={<LearnMoreIcon />} icon={<LearnMoreIcon />}
> >
{I18n.t('site_settings.authentication.learn_more')} {I18n.t('site_settings.authentication.learn_more')}

View File

@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { useForm, SubmitHandler, Controller } from 'react-hook-form'; import { useForm, SubmitHandler } from 'react-hook-form';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
import Box from '../../common/Box'; import Box from '../../common/Box';
@@ -21,16 +21,11 @@ import { DangerText, SmallMutedText } from '../../common/CustomTexts';
import { getLabel, getValidationMessage } from '../../../helpers/formUtils'; import { getLabel, getValidationMessage } from '../../../helpers/formUtils';
import IBoardJSON from '../../../interfaces/json/IBoard'; import IBoardJSON from '../../../interfaces/json/IBoard';
import ActionLink from '../../common/ActionLink'; import ActionLink from '../../common/ActionLink';
import { CancelIcon, DeleteIcon, EditIcon, LearnMoreIcon } from '../../common/Icons'; import { LearnMoreIcon } from '../../common/Icons';
import Dropzone from '../../common/Dropzone';
export interface ISiteSettingsGeneralForm { export interface ISiteSettingsGeneralForm {
siteName: string; siteName: string;
siteLogo?: File; siteLogo: string;
shouldDeleteSiteLogo: boolean;
oldSiteLogo: string;
siteFavicon?: File;
shouldDeleteSiteFavicon: boolean;
brandDisplaySetting: string; brandDisplaySetting: string;
locale: string; locale: string;
useBrowserLocale: boolean; useBrowserLocale: boolean;
@@ -39,7 +34,6 @@ export interface ISiteSettingsGeneralForm {
isPrivate: boolean; isPrivate: boolean;
allowAnonymousFeedback: boolean; allowAnonymousFeedback: boolean;
feedbackApprovalPolicy: string; feedbackApprovalPolicy: string;
allowAttachmentUpload: boolean;
logoLinksTo: string; logoLinksTo: string;
logoCustomUrl?: string; logoCustomUrl?: string;
showRoadmapInHeader: boolean; showRoadmapInHeader: boolean;
@@ -52,8 +46,6 @@ export interface ISiteSettingsGeneralForm {
interface Props { interface Props {
originForm: ISiteSettingsGeneralForm; originForm: ISiteSettingsGeneralForm;
siteLogoUrl?: string;
siteFaviconUrl?: string;
boards: IBoardJSON[]; boards: IBoardJSON[];
isMultiTenant: boolean; isMultiTenant: boolean;
authenticityToken: string; authenticityToken: string;
@@ -63,11 +55,7 @@ interface Props {
updateTenant( updateTenant(
siteName: string, siteName: string,
siteLogo: File, siteLogo: string,
shouldDeleteSiteLogo: boolean,
oldSiteLogo: string,
siteFavicon: File,
shouldDeleteSiteFavicon: boolean,
brandDisplaySetting: string, brandDisplaySetting: string,
locale: string, locale: string,
useBrowserLocale: boolean, useBrowserLocale: boolean,
@@ -76,7 +64,6 @@ interface Props {
isPrivate: boolean, isPrivate: boolean,
allowAnonymousFeedback: boolean, allowAnonymousFeedback: boolean,
feedbackApprovalPolicy: string, feedbackApprovalPolicy: string,
allowAttachmentUpload: boolean,
logoLinksTo: string, logoLinksTo: string,
logoCustomUrl: string, logoCustomUrl: string,
showRoadmapInHeader: boolean, showRoadmapInHeader: boolean,
@@ -91,8 +78,6 @@ interface Props {
const GeneralSiteSettingsP = ({ const GeneralSiteSettingsP = ({
originForm, originForm,
siteLogoUrl,
siteFaviconUrl,
boards, boards,
isMultiTenant, isMultiTenant,
authenticityToken, authenticityToken,
@@ -106,15 +91,10 @@ const GeneralSiteSettingsP = ({
handleSubmit, handleSubmit,
formState: { isDirty, isSubmitSuccessful, errors }, formState: { isDirty, isSubmitSuccessful, errors },
watch, watch,
control,
} = useForm<ISiteSettingsGeneralForm>({ } = useForm<ISiteSettingsGeneralForm>({
defaultValues: { defaultValues: {
siteName: originForm.siteName, siteName: originForm.siteName,
siteLogo: null, siteLogo: originForm.siteLogo,
shouldDeleteSiteLogo: false,
oldSiteLogo: originForm.oldSiteLogo,
siteFavicon: null,
shouldDeleteSiteFavicon: false,
brandDisplaySetting: originForm.brandDisplaySetting, brandDisplaySetting: originForm.brandDisplaySetting,
locale: originForm.locale, locale: originForm.locale,
useBrowserLocale: originForm.useBrowserLocale, useBrowserLocale: originForm.useBrowserLocale,
@@ -123,7 +103,6 @@ const GeneralSiteSettingsP = ({
isPrivate: originForm.isPrivate, isPrivate: originForm.isPrivate,
allowAnonymousFeedback: originForm.allowAnonymousFeedback, allowAnonymousFeedback: originForm.allowAnonymousFeedback,
feedbackApprovalPolicy: originForm.feedbackApprovalPolicy, feedbackApprovalPolicy: originForm.feedbackApprovalPolicy,
allowAttachmentUpload: originForm.allowAttachmentUpload,
logoLinksTo: originForm.logoLinksTo, logoLinksTo: originForm.logoLinksTo,
logoCustomUrl: originForm.logoCustomUrl, logoCustomUrl: originForm.logoCustomUrl,
showRoadmapInHeader: originForm.showRoadmapInHeader, showRoadmapInHeader: originForm.showRoadmapInHeader,
@@ -138,11 +117,7 @@ const GeneralSiteSettingsP = ({
const onSubmit: SubmitHandler<ISiteSettingsGeneralForm> = data => { const onSubmit: SubmitHandler<ISiteSettingsGeneralForm> = data => {
updateTenant( updateTenant(
data.siteName, data.siteName,
data.siteLogo ? data.siteLogo : null, data.siteLogo,
data.shouldDeleteSiteLogo,
data.oldSiteLogo,
data.siteFavicon ? data.siteFavicon : null,
data.shouldDeleteSiteFavicon,
data.brandDisplaySetting, data.brandDisplaySetting,
data.locale, data.locale,
data.useBrowserLocale, data.useBrowserLocale,
@@ -151,7 +126,6 @@ const GeneralSiteSettingsP = ({
data.isPrivate, data.isPrivate,
data.allowAnonymousFeedback, data.allowAnonymousFeedback,
data.feedbackApprovalPolicy, data.feedbackApprovalPolicy,
data.allowAttachmentUpload,
data.logoLinksTo, data.logoLinksTo,
data.logoCustomUrl, data.logoCustomUrl,
data.showRoadmapInHeader, data.showRoadmapInHeader,
@@ -170,6 +144,8 @@ const GeneralSiteSettingsP = ({
}); });
}; };
const customDomain = watch('customDomain');
React.useEffect(() => { React.useEffect(() => {
if (window.location.hash) { if (window.location.hash) {
const anchor = window.location.hash.substring(1); const anchor = window.location.hash.substring(1);
@@ -187,13 +163,6 @@ const GeneralSiteSettingsP = ({
} }
}, []); }, []);
const customDomain = watch('customDomain');
const shouldDeleteSiteLogo = watch('shouldDeleteSiteLogo');
const shouldDeleteSiteFavicon = watch('shouldDeleteSiteFavicon');
const [showSiteLogoDropzone, setShowSiteLogoDropzone] = React.useState([null, undefined, ''].includes(siteLogoUrl));
const [showSiteFaviconDropzone, setShowSiteFaviconDropzone] = React.useState([null, undefined, ''].includes(siteFaviconUrl));
return ( return (
<> <>
<Box customClass="generalSiteSettingsContainer"> <Box customClass="generalSiteSettingsContainer">
@@ -201,7 +170,7 @@ const GeneralSiteSettingsP = ({
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="formRow"> <div className="formRow">
<div className="formGroup col-6"> <div className="formGroup col-4">
<label htmlFor="siteName">{ getLabel('tenant', 'site_name') }</label> <label htmlFor="siteName">{ getLabel('tenant', 'site_name') }</label>
<input <input
{...register('siteName', { required: true })} {...register('siteName', { required: true })}
@@ -211,7 +180,17 @@ const GeneralSiteSettingsP = ({
<DangerText>{errors.siteName && getValidationMessage(errors.siteName.type, 'tenant', 'site_name')}</DangerText> <DangerText>{errors.siteName && getValidationMessage(errors.siteName.type, 'tenant', 'site_name')}</DangerText>
</div> </div>
<div className="formGroup col-6"> <div className="formGroup col-4">
<label htmlFor="siteLogo">{ getLabel('tenant', 'site_logo') }</label>
<input
{...register('siteLogo')}
placeholder='https://example.com/logo.png'
id="siteLogo"
className="formControl"
/>
</div>
<div className="formGroup col-4">
<label htmlFor="brandSetting">{ getLabel('tenant_setting', 'brand_display') }</label> <label htmlFor="brandSetting">{ getLabel('tenant_setting', 'brand_display') }</label>
<select <select
{...register('brandDisplaySetting')} {...register('brandDisplaySetting')}
@@ -232,177 +211,6 @@ const GeneralSiteSettingsP = ({
</option> </option>
</select> </select>
</div> </div>
{/* Hidden oldSiteLogo field for backwards compatibility */}
<div className="formGroup d-none">
<label htmlFor="oldSiteLogo">{ getLabel('tenant', 'site_logo') }</label>
<input
{...register('oldSiteLogo')}
placeholder='https://example.com/logo.png'
id="oldSiteLogo"
className="formControl"
/>
</div>
</div>
<div className="formRow">
<div className="formGroup col-6">
<label htmlFor="siteLogo">{ getLabel('tenant', 'site_logo') }</label>
{
siteLogoUrl &&
<div className={`siteLogoPreview${shouldDeleteSiteLogo ? ' siteLogoPreviewShouldDelete' : ''}`}>
<img src={siteLogoUrl} alt={`${originForm.siteName} logo`} className="siteLogoPreviewImg" />
</div>
}
<div className="siteLogoActions">
{
(siteLogoUrl && !shouldDeleteSiteLogo) &&
(showSiteLogoDropzone ?
<ActionLink
onClick={() => setShowSiteLogoDropzone(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => setShowSiteLogoDropzone(true)}
icon={<EditIcon />}
>
{I18n.t('common.buttons.edit')}
</ActionLink>)
}
{
(siteLogoUrl && !showSiteLogoDropzone) &&
(shouldDeleteSiteLogo ?
<Controller
name="shouldDeleteSiteLogo"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
)}
/>
:
<Controller
name="shouldDeleteSiteLogo"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(true)}
icon={<DeleteIcon />}
>
{I18n.t('common.buttons.delete')}
</ActionLink>
)}
/>
)
}
</div>
{
showSiteLogoDropzone &&
<Controller
name="siteLogo"
control={control}
render={({ field }) => (
<Dropzone
files={field.value ? [field.value] : []}
setFiles={files => files.length > 0 ? field.onChange(files[0]) : field.onChange(null)}
maxSizeKB={256}
maxFiles={1}
/>
)}
/>
}
</div>
<div className="formGroup col-6">
<label htmlFor="siteFavicon">{ getLabel('tenant', 'site_favicon') }</label>
{
siteFaviconUrl &&
<div className={`siteFaviconPreview${shouldDeleteSiteFavicon ? ' siteFaviconPreviewShouldDelete' : ''}`}>
<img src={siteFaviconUrl} alt={`${originForm.siteName} favicon`} className="siteFaviconPreviewImg" />
</div>
}
<div className="siteFaviconActions">
{
(siteFaviconUrl && !shouldDeleteSiteFavicon) &&
(showSiteFaviconDropzone ?
<ActionLink
onClick={() => setShowSiteFaviconDropzone(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
:
<ActionLink
onClick={() => setShowSiteFaviconDropzone(true)}
icon={<EditIcon />}
>
{I18n.t('common.buttons.edit')}
</ActionLink>)
}
{
(siteFaviconUrl && !showSiteFaviconDropzone) &&
(shouldDeleteSiteFavicon ?
<Controller
name="shouldDeleteSiteFavicon"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(false)}
icon={<CancelIcon />}
>
{I18n.t('common.buttons.cancel')}
</ActionLink>
)}
/>
:
<Controller
name="shouldDeleteSiteFavicon"
control={control}
render={({ field }) => (
<ActionLink
onClick={() => field.onChange(true)}
icon={<DeleteIcon />}
>
{I18n.t('common.buttons.delete')}
</ActionLink>
)}
/>
)
}
</div>
{
showSiteFaviconDropzone &&
<Controller
name="siteFavicon"
control={control}
render={({ field }) => (
<Dropzone
files={field.value ? [field.value] : []}
setFiles={files => files.length > 0 ? field.onChange(files[0]) : field.onChange(null)}
maxSizeKB={64}
maxFiles={1}
accept={['image/x-icon', 'image/icon', 'image/png', 'image/jpeg', 'image/jpg']}
/>
)}
/>
}
</div>
</div> </div>
<div className="formGroup"> <div className="formGroup">
@@ -470,7 +278,7 @@ const GeneralSiteSettingsP = ({
} }
<div style={{marginTop: 8}}> <div style={{marginTop: 8}}>
<ActionLink <ActionLink
onClick={() => window.open('https://docs.astuto.io/custom-domain', '_blank')} onClick={() => window.open('https://github.com/astuto/astuto-docs/blob/main/docs/custom-domain.md', '_blank')}
icon={<LearnMoreIcon />} icon={<LearnMoreIcon />}
> >
{I18n.t('site_settings.general.custom_domain_learn_more')} {I18n.t('site_settings.general.custom_domain_learn_more')}
@@ -527,16 +335,6 @@ const GeneralSiteSettingsP = ({
{ I18n.t('site_settings.general.feedback_approval_policy_help') } { I18n.t('site_settings.general.feedback_approval_policy_help') }
</SmallMutedText> </SmallMutedText>
</div> </div>
<div className="formGroup">
<div className="checkboxSwitch">
<input {...register('allowAttachmentUpload')} type="checkbox" id="allow_attachment_upload" />
<label htmlFor="allow_attachment_upload">{ getLabel('tenant_setting', 'allow_attachment_upload') }</label>
<SmallMutedText>
{ I18n.t('site_settings.general.allow_attachment_upload_help') }
</SmallMutedText>
</div>
</div>
</div> </div>
<div id="header" className="settingsGroup"> <div id="header" className="settingsGroup">

View File

@@ -10,8 +10,6 @@ import { ISiteSettingsGeneralForm } from './GeneralSiteSettingsP';
interface Props { interface Props {
originForm: ISiteSettingsGeneralForm; originForm: ISiteSettingsGeneralForm;
siteLogoUrl?: string;
siteFaviconUrl?: string;
boards: IBoardJSON[]; boards: IBoardJSON[];
isMultiTenant: boolean; isMultiTenant: boolean;
authenticityToken: string; authenticityToken: string;
@@ -31,8 +29,6 @@ class GeneralSiteSettingsRoot extends React.Component<Props> {
<Provider store={this.store}> <Provider store={this.store}>
<GeneralSiteSettings <GeneralSiteSettings
originForm={this.props.originForm} originForm={this.props.originForm}
siteLogoUrl={this.props.siteLogoUrl}
siteFaviconUrl={this.props.siteFaviconUrl}
boards={this.props.boards} boards={this.props.boards}
isMultiTenant={this.props.isMultiTenant} isMultiTenant={this.props.isMultiTenant}
authenticityToken={this.props.authenticityToken} authenticityToken={this.props.authenticityToken}

View File

@@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import Gravatar from 'react-gravatar';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import I18n from 'i18n-js'; import I18n from 'i18n-js';
@@ -12,7 +13,6 @@ import IInvitation from '../../../interfaces/IInvitation';
import friendlyDate, { fromRailsStringToJavascriptDate, nMonthsAgo } from '../../../helpers/datetime'; import friendlyDate, { fromRailsStringToJavascriptDate, nMonthsAgo } from '../../../helpers/datetime';
import ActionLink from '../../common/ActionLink'; import ActionLink from '../../common/ActionLink';
import { TestIcon } from '../../common/Icons'; import { TestIcon } from '../../common/Icons';
import Avatar from '../../common/Avatar';
interface Props { interface Props {
siteName: string; siteName: string;
@@ -218,8 +218,7 @@ const Invitations = ({ siteName, invitations, currentUserEmail, authenticityToke
invitationsToDisplay.map((invitation, i) => ( invitationsToDisplay.map((invitation, i) => (
<li key={i} className="invitationListItem"> <li key={i} className="invitationListItem">
<div className="invitationUserInfo"> <div className="invitationUserInfo">
<Avatar email={invitation.email} size={42} customClass="userAvatar" /> <Gravatar email={invitation.email} size={42} className="gravatar userGravatar" />
<span className="invitationEmail">{ invitation.email }</span> <span className="invitationEmail">{ invitation.email }</span>
</div> </div>

View File

@@ -48,7 +48,7 @@ const WebhooksIndexPage = ({
<p style={{textAlign: 'left'}}> <p style={{textAlign: 'left'}}>
<ActionLink <ActionLink
onClick={() => window.open('https://docs.astuto.io/webhooks/webhooks-introduction/', '_blank')} onClick={() => window.open('https://github.com/astuto/astuto-docs/blob/main/docs/webhooks/webhooks-introduction.md', '_blank')}
icon={<LearnMoreIcon />} icon={<LearnMoreIcon />}
> >
{I18n.t('site_settings.webhooks.learn_more')} {I18n.t('site_settings.webhooks.learn_more')}

View File

@@ -7,6 +7,7 @@ import ConfirmEmailSignUpPage from './ConfirmEmailSignUpPage';
import ConfirmOAuthSignUpPage from './ConfirmOAuthSignUpPage'; import ConfirmOAuthSignUpPage from './ConfirmOAuthSignUpPage';
import { IOAuth } from '../../interfaces/IOAuth'; import { IOAuth } from '../../interfaces/IOAuth';
import HttpStatus from '../../constants/http_status'; import HttpStatus from '../../constants/http_status';
import Box from '../common/Box';
interface Props { interface Props {
oAuthLoginCompleted: boolean; oAuthLoginCompleted: boolean;
@@ -116,57 +117,70 @@ const TenantSignUpP = ({
}); });
} }
// return (
// <>
// <img src={astutoLogoImage} width={64} height={64} className="astutoLogo" />
// <div className="tenantSignUpContainer">
// {
// (currentStep === 1 || currentStep === 2) &&
// <UserSignUpForm
// currentStep={currentStep}
// setCurrentStep={setCurrentStep}
// authMethod={authMethod}
// setAuthMethod={setAuthMethod}
// oAuths={oAuths}
// userData={userData}
// setUserData={setUserData}
// setGoneBack={setGoneBack}
// />
// }
// {
// (goneBack || currentStep === 2) &&
// <TenantSignUpForm
// isSubmitting={isSubmitting}
// error={error}
// handleSignUpSubmit={handleSignUpSubmit}
// trialPeriodDays={trialPeriodDays}
// currentStep={currentStep}
// setCurrentStep={setCurrentStep}
// />
// }
// {
// currentStep === 3 && authMethod === 'oauth' &&
// <ConfirmOAuthSignUpPage
// baseUrl={baseUrl}
// subdomain={tenantData.subdomain}
// feedbackSpaceCreatedImage={feedbackSpaceCreatedImage}
// />
// }
// {
// currentStep === 3 && authMethod === 'email' &&
// <ConfirmEmailSignUpPage
// subdomain={tenantData.subdomain}
// userEmail={userData.email}
// pendingTenantImage={pendingTenantImage}
// />
// }
// </div>
// </>
// );
return ( return (
<> <>
<img src={astutoLogoImage} width={64} height={64} className="astutoLogo" /> <img src={astutoLogoImage} width={64} height={64} className="astutoLogo" />
<div className="tenantSignUpContainer"> <div className="tenantSignUpContainer">
{ <Box>
(currentStep === 1 || currentStep === 2) && <p>It is not possible to sign up to Astuto.</p>
<UserSignUpForm <p>You can <a href="https://github.com/astuto/astuto">self-host your own instance</a> instead.</p>
currentStep={currentStep} </Box>
setCurrentStep={setCurrentStep} </div>
authMethod={authMethod}
setAuthMethod={setAuthMethod}
oAuths={oAuths}
userData={userData}
setUserData={setUserData}
setGoneBack={setGoneBack}
/>
}
{
(goneBack || currentStep === 2) &&
<TenantSignUpForm
isSubmitting={isSubmitting}
error={error}
handleSignUpSubmit={handleSignUpSubmit}
trialPeriodDays={trialPeriodDays}
currentStep={currentStep}
setCurrentStep={setCurrentStep}
/>
}
{
currentStep === 3 && authMethod === 'oauth' &&
<ConfirmOAuthSignUpPage
baseUrl={baseUrl}
subdomain={tenantData.subdomain}
feedbackSpaceCreatedImage={feedbackSpaceCreatedImage}
/>
}
{
currentStep === 3 && authMethod === 'email' &&
<ConfirmEmailSignUpPage
subdomain={tenantData.subdomain}
userEmail={userData.email}
pendingTenantImage={pendingTenantImage}
/>
}
</div>
</> </>
); )
} }
export default TenantSignUpP; export default TenantSignUpP;

View File

@@ -78,6 +78,7 @@ const UserSignUpForm = ({
<OAuthProviderLink <OAuthProviderLink
oAuthId={oAuth.id} oAuthId={oAuth.id}
oAuthName={oAuth.name} oAuthName={oAuth.name}
oAuthLogo={oAuth.logo}
oAuthReason='tenantsignup' oAuthReason='tenantsignup'
isSignUp isSignUp
key={i} key={i}

View File

@@ -1,55 +0,0 @@
import * as React from 'react';
import I18n from 'i18n-js';
import ActionLink from '../common/ActionLink';
import { DeleteIcon } from '../common/Icons';
import buildRequestHeaders from '../../helpers/buildRequestHeaders';
import Spinner from '../common/Spinner';
interface Props {
deleteAvatarEndpoint: string;
userProfileUrl: string;
authenticityToken: string;
}
const DeleteAvatarButton = ({ deleteAvatarEndpoint, userProfileUrl, authenticityToken }: Props) => {
const [isDeleting, setIsDeleting] = React.useState(false);
const [error, setError] = React.useState('');
return (
<>
<ActionLink
icon={<DeleteIcon />}
onClick={async () => {
setIsDeleting(true);
try {
const response = await fetch(deleteAvatarEndpoint, {
method: 'DELETE',
headers: buildRequestHeaders(authenticityToken),
});
if (response.ok) {
window.location.href = userProfileUrl;
} else {
throw new Error();
}
} catch {
setError(I18n.t('common.errors.unknown'));
}
setIsDeleting(false);
if (error) {
alert(error);
}
}
}>
{ I18n.t('common.buttons.delete') }
</ActionLink>
{ isDeleting && <Spinner /> }
</>
);
}
export default DeleteAvatarButton;

View File

@@ -81,14 +81,6 @@ const GenerateApiKeyDialog = ({
</> </>
} }
<br /><br />
<ActionLink
icon={<LearnMoreIcon />}
onClick={() => window.open('https://docs.astuto.io/api', '_blank')}
>
{I18n.t('common.forms.api_key.api_key_learn_more')}
</ActionLink>
{ error && <DangerText>{error}</DangerText> } { error && <DangerText>{error}</DangerText> }
</> </>
); );

View File

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

View File

@@ -1,96 +0,0 @@
import React, {useEffect} from 'react';
import {useDropzone} from 'react-dropzone';
import I18n from 'i18n-js';
import { SmallMutedText } from './CustomTexts';
import ActionLink from './ActionLink';
import { DeleteIcon } from './Icons';
interface Props {
files: any[];
setFiles: React.Dispatch<React.SetStateAction<any[]>>;
maxSizeKB?: number;
maxFiles?: number;
accept?: string[];
customStyle?: { [key: string]: string };
}
const Dropzone = ({
files,
setFiles,
maxSizeKB = 256,
maxFiles = 1,
accept = ['image/png', 'image/jpeg', 'image/jpg', 'image/x-icon', 'image/icon', 'image/svg+xml', 'image/svg', 'image/webp'],
customStyle = {}
}: Props) => {
const acceptDict = accept.reduce((acc, type) => {
acc[type] = [];
return acc;
}, {});
const {
getRootProps,
getInputProps,
isDragAccept,
isDragReject
} = useDropzone({
accept: acceptDict,
maxSize: maxSizeKB * 1024,
maxFiles: maxFiles,
disabled: files.length >= maxFiles,
onDrop: (acceptedFiles, fileRejections) => {
if (fileRejections.length > 0) {
alert(I18n.t('common.errors.invalid_file', { errors: fileRejections.map(rejection => rejection.errors[0].message).join(', ') }));
}
// If number of files already uploaded is lower than maxFiles, add the new files
const filesAfterDrop = (files.length + acceptedFiles.length <= maxFiles) ? files.concat(acceptedFiles) : acceptedFiles;
setFiles(filesAfterDrop.map(file => Object.assign(file, {
preview: URL.createObjectURL(file)
})));
},
});
const thumbnails = files.map(file => (
<div className="thumbnailContainer" key={file.name}>
<div className="thumbnail">
<div className="thumbnailInner">
<img
src={file.preview}
className="thumbnailImage"
// Revoke data uri after image is loaded
onLoad={() => { URL.revokeObjectURL(file.preview) }}
/>
</div>
</div>
<ActionLink
onClick={() => setFiles(files.filter(f => f !== file))}
icon={<DeleteIcon />}
customClass="removeThumbnail"
>
{I18n.t('common.buttons.delete')}
</ActionLink>
</div>
));
useEffect(() => {
// Make sure to revoke the data uris to avoid memory leaks, will run on unmount
return () => files.forEach(file => URL.revokeObjectURL(file.preview));
}, [files]);
return (
<section>
<div {...getRootProps({className: 'dropzone' + (isDragAccept ? ' dropzone-accept' : isDragReject ? ' dropzone-reject' : '') + (files.length >= maxFiles ? ' dropzone-disabled' : '')})} style={customStyle}>
<input {...getInputProps()} />
<SmallMutedText>{I18n.t('common.drag_and_drop', { maxCount: maxFiles, maxSize: maxSizeKB })}</SmallMutedText>
</div>
<aside className="thumbnailsContainer">
{thumbnails}
</aside>
</section>
);
}
export default Dropzone;

View File

@@ -17,9 +17,8 @@ import {
MdCheck, MdCheck,
MdClear, MdClear,
MdAdd, MdAdd,
MdAttachFile,
} from 'react-icons/md'; } from 'react-icons/md';
import { FaUserNinja, FaMarkdown, FaRegImage } from "react-icons/fa"; import { FaUserNinja, FaMarkdown } from "react-icons/fa";
import { FaDroplet } from "react-icons/fa6"; import { FaDroplet } from "react-icons/fa6";
export const EditIcon = () => <FiEdit />; export const EditIcon = () => <FiEdit />;
@@ -107,8 +106,4 @@ export const MarkdownIcon = ({size = 24, style = {}}) => (
</a> </a>
<Tooltip id="markdown-tooltip" /> <Tooltip id="markdown-tooltip" />
</> </>
); );
export const AttachIcon = ({size = 24}) => <MdAttachFile size={size} />;
export const ImageIcon = ({size = 24}) => <FaRegImage size={size} />;

View File

@@ -3,7 +3,7 @@ import I18n from 'i18n-js';
const PoweredByLink = () => ( const PoweredByLink = () => (
<div className="poweredBy"> <div className="poweredBy">
<a href={`http://astuto.io/?utm_campaign=poweredby&utm_source=${window.location.hostname}`} target="_blank"> <a href="https://github.com/astuto/astuto/" target="_blank">
{ I18n.t('common.powered_by') } Astuto { I18n.t('common.powered_by') } Astuto
</a> </a>
</div> </div>

View File

@@ -24,12 +24,12 @@ const mapDispatchToProps = (dispatch: any) => ({
dispatch(requestOAuths()); dispatch(requestOAuths());
}, },
onSubmitOAuth(oAuth: IOAuth, oAuthLogo: File = null, authenticityToken: string): Promise<any> { onSubmitOAuth(oAuth: IOAuth, authenticityToken: string): Promise<any> {
return dispatch(submitOAuth(oAuth, oAuthLogo, authenticityToken)); return dispatch(submitOAuth(oAuth, authenticityToken));
}, },
onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, shouldDeleteLogo: boolean, authenticityToken: string): Promise<any> { onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, authenticityToken: string): Promise<any> {
return dispatch(updateOAuth({id, form, isEnabled: undefined, shouldDeleteLogo, authenticityToken})); return dispatch(updateOAuth({id, form, authenticityToken}));
}, },
onToggleEnabledOAuth(id: number, isEnabled: boolean, authenticityToken: string) { onToggleEnabledOAuth(id: number, isEnabled: boolean, authenticityToken: string) {

View File

@@ -44,13 +44,9 @@ const mapDispatchToProps = (dispatch) => ({
body: string, body: string,
parentId: number, parentId: number,
isPostUpdate: boolean, isPostUpdate: boolean,
attachments: File[],
onSuccess: Function,
authenticityToken: string, authenticityToken: string,
) { ) {
dispatch(submitComment(postId, body, parentId, isPostUpdate, attachments, authenticityToken)).then(res => { dispatch(submitComment(postId, body, parentId, isPostUpdate, authenticityToken));
if (res && res.status === HttpStatus.Created) onSuccess();
});
}, },
updateComment( updateComment(
@@ -58,12 +54,10 @@ const mapDispatchToProps = (dispatch) => ({
commentId: number, commentId: number,
body: string, body: string,
isPostUpdate: boolean, isPostUpdate: boolean,
attachmentsToDelete: number[],
attachments: File[],
onSuccess: Function, onSuccess: Function,
authenticityToken: string, authenticityToken: string,
) { ) {
dispatch(updateComment(postId, commentId, body, isPostUpdate, attachmentsToDelete, attachments, authenticityToken)).then(res => { dispatch(updateComment(postId, commentId, body, isPostUpdate, authenticityToken)).then(res => {
if (res && res.status === HttpStatus.OK) onSuccess(); if (res && res.status === HttpStatus.OK) onSuccess();
}); });
}, },

View File

@@ -18,11 +18,7 @@ const mapStateToProps = (state: State) => ({
const mapDispatchToProps = (dispatch: any) => ({ const mapDispatchToProps = (dispatch: any) => ({
updateTenant( updateTenant(
siteName: string, siteName: string,
siteLogo: File, siteLogo: string,
shouldDeleteSiteLogo: boolean,
oldSiteLogo: string,
siteFavicon: File,
shouldDeleteSiteFavicon: boolean,
brandDisplaySetting: TenantSettingBrandDisplay, brandDisplaySetting: TenantSettingBrandDisplay,
locale: string, locale: string,
useBrowserLocale: boolean, useBrowserLocale: boolean,
@@ -31,7 +27,6 @@ const mapDispatchToProps = (dispatch: any) => ({
isPrivate: boolean, isPrivate: boolean,
allowAnonymousFeedback: boolean, allowAnonymousFeedback: boolean,
feedbackApprovalPolicy: TenantSettingFeedbackApprovalPolicy, feedbackApprovalPolicy: TenantSettingFeedbackApprovalPolicy,
allowAttachmentUpload: boolean,
logoLinksTo: TenantSettingLogoLinksTo, logoLinksTo: TenantSettingLogoLinksTo,
logoCustomUrl: string, logoCustomUrl: string,
showRoadmapInHeader: boolean, showRoadmapInHeader: boolean,
@@ -45,10 +40,6 @@ const mapDispatchToProps = (dispatch: any) => ({
return dispatch(updateTenant({ return dispatch(updateTenant({
siteName, siteName,
siteLogo, siteLogo,
shouldDeleteSiteLogo,
oldSiteLogo,
siteFavicon,
shouldDeleteSiteFavicon,
tenantSetting: { tenantSetting: {
brand_display: brandDisplaySetting, brand_display: brandDisplaySetting,
use_browser_locale: useBrowserLocale, use_browser_locale: useBrowserLocale,
@@ -56,7 +47,6 @@ const mapDispatchToProps = (dispatch: any) => ({
is_private: isPrivate, is_private: isPrivate,
allow_anonymous_feedback: allowAnonymousFeedback, allow_anonymous_feedback: allowAnonymousFeedback,
feedback_approval_policy: feedbackApprovalPolicy, feedback_approval_policy: feedbackApprovalPolicy,
allow_attachment_upload: allowAttachmentUpload,
logo_links_to: logoLinksTo, logo_links_to: logoLinksTo,
logo_custom_url: logoCustomUrl, logo_custom_url: logoCustomUrl,
show_roadmap_in_header: showRoadmapInHeader, show_roadmap_in_header: showRoadmapInHeader,

View File

@@ -44,11 +44,9 @@ const mapDispatchToProps = (dispatch) => ({
description: string, description: string,
boardId: number, boardId: number,
postStatusId: number, postStatusId: number,
attachmentsToDelete: number[],
attachments: File[],
authenticityToken: string, authenticityToken: string,
) { ) {
return dispatch(updatePost(postId, title, description, boardId, postStatusId, attachmentsToDelete, attachments, authenticityToken)); return dispatch(updatePost(postId, title, description, boardId, postStatusId, authenticityToken));
}, },
toggleEditMode() { toggleEditMode() {

View File

@@ -1,20 +0,0 @@
const buildFormData = (data: { [key: string]: any }) => {
const formData = new FormData();
for (const [key, value] of Object.entries(data)) {
if (value !== null && value !== undefined) {
if (Array.isArray(value)) {
// If the value is an array, append each array item to the form data separately
value.forEach((item) => {
formData.append(key, item);
});
} else {
formData.append(key, value);
}
}
}
return formData;
}
export default buildFormData;

View File

@@ -1,6 +1,6 @@
const buildRequestHeaders = (authenticityToken: string, contentType: string = 'application/json') => ({ const buildRequestHeaders = (authenticityToken: string) => ({
Accept: 'application/json', Accept: 'application/json',
'Content-Type': contentType, 'Content-Type': 'application/json',
'X-CSRF-Token': authenticityToken, 'X-CSRF-Token': authenticityToken,
}); });

View File

@@ -3,10 +3,8 @@ interface IComment {
body: string; body: string;
parentId: number; parentId: number;
isPostUpdate: boolean; isPostUpdate: boolean;
attachmentUrls?: string[];
userFullName: string; userFullName: string;
userEmail: string; userEmail: string;
userAvatar?: string;
userRole: number; userRole: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View File

@@ -4,7 +4,6 @@ interface ILike {
id: number; id: number;
fullName: string; fullName: string;
email: string; email: string;
userAvatar?: string;
} }
export default ILike; export default ILike;
@@ -13,5 +12,4 @@ export const likeJSON2JS = (likeJSON: ILikeJSON): ILike => ({
id: likeJSON.id, id: likeJSON.id,
fullName: likeJSON.full_name, fullName: likeJSON.full_name,
email: likeJSON.email, email: likeJSON.email,
userAvatar: likeJSON.user_avatar,
}); });

View File

@@ -1,7 +1,7 @@
export interface IOAuth { export interface IOAuth {
id?: number; id?: number;
name: string; name: string;
logoUrl?: string; logo?: string;
isEnabled: boolean; isEnabled: boolean;
clientId: string; clientId: string;
clientSecret?: string; clientSecret?: string;
@@ -20,7 +20,7 @@ export interface IOAuth {
export interface IOAuthJSON { export interface IOAuthJSON {
id: string; id: string;
name: string; name: string;
logo_url?: string; logo?: string;
is_enabled: boolean; is_enabled: boolean;
client_id: string; client_id: string;
client_secret?: string; client_secret?: string;
@@ -39,7 +39,7 @@ export interface IOAuthJSON {
export const oAuthJSON2JS = (oAuthJSON: IOAuthJSON): IOAuth => ({ export const oAuthJSON2JS = (oAuthJSON: IOAuthJSON): IOAuth => ({
id: parseInt(oAuthJSON.id), id: parseInt(oAuthJSON.id),
name: oAuthJSON.name, name: oAuthJSON.name,
logoUrl: oAuthJSON.logo_url, logo: oAuthJSON.logo,
isEnabled: oAuthJSON.is_enabled, isEnabled: oAuthJSON.is_enabled,
clientId: oAuthJSON.client_id, clientId: oAuthJSON.client_id,
clientSecret: oAuthJSON.client_secret, clientSecret: oAuthJSON.client_secret,
@@ -58,7 +58,7 @@ export const oAuthJSON2JS = (oAuthJSON: IOAuthJSON): IOAuth => ({
export const oAuthJS2JSON = (oAuth: IOAuth) => ({ export const oAuthJS2JSON = (oAuth: IOAuth) => ({
id: oAuth.id?.toString(), id: oAuth.id?.toString(),
name: oAuth.name, name: oAuth.name,
logo_url: oAuth.logoUrl, logo: oAuth.logo,
is_enabled: oAuth.isEnabled, is_enabled: oAuth.isEnabled,
client_id: oAuth.clientId, client_id: oAuth.clientId,
client_secret: oAuth.clientSecret, client_secret: oAuth.clientSecret,

View File

@@ -15,8 +15,6 @@ interface IPost {
title: string; title: string;
slug?: string; slug?: string;
description?: string; description?: string;
attachmentUrls?: string[];
hasAttachments?: boolean;
approvalStatus: PostApprovalStatus; approvalStatus: PostApprovalStatus;
boardId: number; boardId: number;
postStatusId?: number; postStatusId?: number;
@@ -27,7 +25,6 @@ interface IPost {
userId: number; userId: number;
userEmail: string; userEmail: string;
userFullName: string; userFullName: string;
userAvatar?: string;
createdAt: string; createdAt: string;
} }
@@ -38,8 +35,6 @@ export const postJSON2JS = (postJSON: IPostJSON): IPost => ({
title: postJSON.title, title: postJSON.title,
slug: postJSON.slug, slug: postJSON.slug,
description: postJSON.description, description: postJSON.description,
attachmentUrls: postJSON.attachment_urls,
hasAttachments: postJSON.has_attachments,
approvalStatus: postJSON.approval_status, approvalStatus: postJSON.approval_status,
boardId: postJSON.board_id, boardId: postJSON.board_id,
postStatusId: postJSON.post_status_id, postStatusId: postJSON.post_status_id,
@@ -50,6 +45,5 @@ export const postJSON2JS = (postJSON: IPostJSON): IPost => ({
userId: postJSON.user_id, userId: postJSON.user_id,
userEmail: postJSON.user_email, userEmail: postJSON.user_email,
userFullName: postJSON.user_full_name, userFullName: postJSON.user_full_name,
userAvatar: postJSON.user_avatar,
createdAt: postJSON.created_at, createdAt: postJSON.created_at,
}); });

View File

@@ -2,7 +2,6 @@ interface IPostStatusChange {
postStatusId: number; postStatusId: number;
userFullName: string; userFullName: string;
userEmail: string; userEmail: string;
userAvatar?: string;
createdAt: string; createdAt: string;
} }

View File

@@ -1,7 +1,7 @@
interface ITenant { interface ITenant {
id: number; id: number;
siteName: string; siteName: string;
oldSiteLogo: string; siteLogo: string;
locale: string; locale: string;
customDomain?: string; customDomain?: string;
} }

View File

@@ -58,7 +58,6 @@ interface ITenantSetting {
allowed_email_domains?: string; allowed_email_domains?: string;
allow_anonymous_feedback?: boolean; allow_anonymous_feedback?: boolean;
feedback_approval_policy?: TenantSettingFeedbackApprovalPolicy; feedback_approval_policy?: TenantSettingFeedbackApprovalPolicy;
allow_attachment_upload?: boolean;
show_vote_count?: boolean; show_vote_count?: boolean;
show_vote_button_in_board?: boolean; show_vote_button_in_board?: boolean;
show_roadmap_in_header?: boolean; show_roadmap_in_header?: boolean;

View File

@@ -24,7 +24,6 @@ interface IUser {
id: number; id: number;
email: string; email: string;
fullName: string; fullName: string;
avatarUrl?: string;
role: UserRoles; role: UserRoles;
status: UserStatuses; status: UserStatuses;
} }

View File

@@ -3,10 +3,8 @@ interface ICommentJSON {
body: string; body: string;
parent_id: number; parent_id: number;
is_post_update: boolean; is_post_update: boolean;
attachment_urls?: string[];
user_full_name: string; user_full_name: string;
user_email: string; user_email: string;
user_avatar?: string;
user_role: number; user_role: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;

View File

@@ -4,7 +4,6 @@ interface ILikeJSON {
post_id: number; post_id: number;
full_name: string; full_name: string;
email: string; email: string;
user_avatar?: string;
} }
export default ILikeJSON; export default ILikeJSON;

View File

@@ -5,8 +5,6 @@ interface IPostJSON {
title: string; title: string;
slug?: string; slug?: string;
description?: string; description?: string;
attachment_urls?: string[];
has_attachments?: boolean;
approval_status: PostApprovalStatus; approval_status: PostApprovalStatus;
board_id: number; board_id: number;
post_status_id?: number; post_status_id?: number;
@@ -17,7 +15,6 @@ interface IPostJSON {
user_id: number; user_id: number;
user_email: string; user_email: string;
user_full_name: string; user_full_name: string;
user_avatar?: string;
created_at: string; created_at: string;
} }

View File

@@ -2,7 +2,6 @@ interface IPostStatusChangeJSON {
post_status_id: number; post_status_id: number;
user_full_name: string; user_full_name: string;
user_email: string; user_email: string;
user_avatar?: string;
created_at: string; created_at: string;
} }

View File

@@ -1,7 +1,7 @@
interface ITenantJSON { interface ITenantJSON {
id: number; id: number;
site_name: string; site_name: string;
old_site_logo: string; site_logo: string;
brand_display_setting: string; brand_display_setting: string;
locale: string; locale: string;
custom_domain?: string; custom_domain?: string;

View File

@@ -2,7 +2,6 @@ interface IUserJSON {
id: number; id: number;
email: string; email: string;
full_name: string; full_name: string;
avatar_url?: string;
role: string; role: string;
status: string; status: string;
} }

View File

@@ -15,10 +15,8 @@ const initialState: IComment = {
body: '', body: '',
parentId: null, parentId: null,
isPostUpdate: false, isPostUpdate: false,
attachmentUrls: [],
userFullName: '<Unknown user>', userFullName: '<Unknown user>',
userEmail: 'example@example.com', userEmail: 'example@example.com',
userAvatar: undefined,
userRole: 0, userRole: 0,
createdAt: undefined, createdAt: undefined,
updatedAt: undefined, updatedAt: undefined,
@@ -36,10 +34,8 @@ const commentReducer = (
body: action.comment.body, body: action.comment.body,
parentId: action.comment.parent_id, parentId: action.comment.parent_id,
isPostUpdate: action.comment.is_post_update, isPostUpdate: action.comment.is_post_update,
attachmentUrls: action.comment.attachment_urls,
userFullName: action.comment.user_full_name, userFullName: action.comment.user_full_name,
userEmail: action.comment.user_email, userEmail: action.comment.user_email,
userAvatar: action.comment.user_avatar,
userRole: action.comment.user_role, userRole: action.comment.user_role,
createdAt: action.comment.created_at, createdAt: action.comment.created_at,
updatedAt: action.comment.updated_at, updatedAt: action.comment.updated_at,

View File

@@ -42,7 +42,6 @@ const likesReducer = (
id: like.id, id: like.id,
fullName: like.full_name, fullName: like.full_name,
email: like.email, email: like.email,
userAvatar: like.user_avatar,
})), })),
areLoading: false, areLoading: false,
error: '', error: '',
@@ -64,7 +63,6 @@ const likesReducer = (
id: action.like.id, id: action.like.id,
fullName: action.like.full_name, fullName: action.like.full_name,
email: action.like.email, email: action.like.email,
userAvatar: action.like.user_avatar,
}, },
...state.items, ...state.items,
], ],

View File

@@ -15,8 +15,6 @@ const initialState: IPost = {
title: '', title: '',
slug: null, slug: null,
description: null, description: null,
attachmentUrls: [],
hasAttachments: false,
approvalStatus: POST_APPROVAL_STATUS_APPROVED, approvalStatus: POST_APPROVAL_STATUS_APPROVED,
boardId: 0, boardId: 0,
postStatusId: null, postStatusId: null,
@@ -27,7 +25,6 @@ const initialState: IPost = {
userId: 0, userId: 0,
userEmail: '', userEmail: '',
userFullName: '', userFullName: '',
userAvatar: '',
createdAt: '', createdAt: '',
}; };
@@ -44,8 +41,6 @@ const postReducer = (
title: action.post.title, title: action.post.title,
slug: action.post.slug, slug: action.post.slug,
description: action.post.description, description: action.post.description,
attachmentUrls: action.post.attachment_urls,
hasAttachments: action.post.has_attachments,
approvalStatus: action.post.approval_status, approvalStatus: action.post.approval_status,
boardId: action.post.board_id, boardId: action.post.board_id,
postStatusId: action.post.post_status_id, postStatusId: action.post.post_status_id,
@@ -56,7 +51,6 @@ const postReducer = (
userId: action.post.user_id, userId: action.post.user_id,
userEmail: action.post.user_email, userEmail: action.post.user_email,
userFullName: action.post.user_full_name, userFullName: action.post.user_full_name,
userAvatar: action.post.user_avatar,
createdAt: action.post.created_at, createdAt: action.post.created_at,
}; };
@@ -65,7 +59,6 @@ const postReducer = (
...state, ...state,
title: action.post.title, title: action.post.title,
description: action.post.description, description: action.post.description,
attachmentUrls: action.post.attachment_urls,
boardId: action.post.board_id, boardId: action.post.board_id,
postStatusId: action.post.post_status_id, postStatusId: action.post.post_status_id,
approvalStatus: action.post.approval_status, approvalStatus: action.post.approval_status,

View File

@@ -44,7 +44,6 @@ const postStatusChangesReducer = (
postStatusId: postStatusChange.post_status_id, postStatusId: postStatusChange.post_status_id,
userFullName: postStatusChange.user_full_name, userFullName: postStatusChange.user_full_name,
userEmail: postStatusChange.user_email, userEmail: postStatusChange.user_email,
userAvatar: postStatusChange.user_avatar,
createdAt: postStatusChange.created_at, createdAt: postStatusChange.created_at,
})), })),
areLoading: false, areLoading: false,

View File

@@ -44,7 +44,6 @@ const usersReducer = (
id: userJson.id, id: userJson.id,
email: userJson.email, email: userJson.email,
fullName: userJson.full_name, fullName: userJson.full_name,
avatarUrl: userJson.avatar_url,
role: userJson.role, role: userJson.role,
status: userJson.status, status: userJson.status,
})), })),

View File

@@ -4,17 +4,11 @@ class Comment < ApplicationRecord
belongs_to :user belongs_to :user
belongs_to :post belongs_to :post
belongs_to :parent, class_name: 'Comment', optional: true belongs_to :parent, class_name: 'Comment', optional: true
has_many :children, class_name: 'Comment', foreign_key: 'parent_id', dependent: :destroy has_many :children, class_name: 'Comment', foreign_key: 'parent_id', dependent: :destroy
has_many_attached :attachments
after_create :run_webhooks after_create :run_webhooks
validates :body, presence: true validates :body, presence: true
validates :attachments,
content_type: Rails.application.accepted_image_types,
size: { less_than: 2048.kilobytes },
limit: { max: 5 }
private private

View File

@@ -6,7 +6,6 @@ class OAuth < ApplicationRecord
extend FriendlyId extend FriendlyId
has_many :tenant_default_o_auths, dependent: :destroy has_many :tenant_default_o_auths, dependent: :destroy
has_one_attached :logo, service: ENV.fetch('ACTIVE_STORAGE_PUBLIC_SERVICE', :local).to_sym
attr_accessor :state attr_accessor :state
@@ -19,9 +18,6 @@ class OAuth < ApplicationRecord
validates :profile_url, presence: true validates :profile_url, presence: true
validates :scope, presence: true validates :scope, presence: true
validates :json_user_email_path, presence: true validates :json_user_email_path, presence: true
validates :logo,
content_type: Rails.application.accepted_image_types,
size: { less_than: 64.kilobytes }
friendly_id :generate_random_slug, use: :scoped, scope: :tenant_id friendly_id :generate_random_slug, use: :scoped, scope: :tenant_id
@@ -29,10 +25,6 @@ class OAuth < ApplicationRecord
tenant_id == nil tenant_id == nil
end end
def logo_url
self.logo.attached? ? self.logo.blob.url : nil
end
def callback_url def callback_url
# Default OAuths are available to all tenants # Default OAuths are available to all tenants
# but must have a single callback url: # but must have a single callback url:

View File

@@ -14,8 +14,6 @@ class Post < ApplicationRecord
has_many :comments, dependent: :destroy has_many :comments, dependent: :destroy
has_many :post_status_changes, dependent: :destroy has_many :post_status_changes, dependent: :destroy
has_many_attached :attachments
after_create :run_new_post_webhooks after_create :run_new_post_webhooks
after_destroy :run_delete_post_webhooks after_destroy :run_delete_post_webhooks
@@ -26,10 +24,6 @@ class Post < ApplicationRecord
] ]
validates :title, presence: true, length: { in: 4..128 } validates :title, presence: true, length: { in: 4..128 }
validates :attachments,
content_type: Rails.application.accepted_image_types,
size: { less_than: 2048.kilobytes },
limit: { max: 5 }
paginates_per Rails.application.posts_per_page paginates_per Rails.application.posts_per_page

View File

@@ -12,9 +12,6 @@ class Tenant < ApplicationRecord
# used to query all globally enabled default oauths that are also enabled by the specific tenant # used to query all globally enabled default oauths that are also enabled by the specific tenant
has_many :default_o_auths, -> { where tenant_id: nil, is_enabled: true }, through: :tenant_default_o_auths, source: :o_auth has_many :default_o_auths, -> { where tenant_id: nil, is_enabled: true }, through: :tenant_default_o_auths, source: :o_auth
has_one_attached :site_logo, service: ENV.fetch('ACTIVE_STORAGE_PUBLIC_SERVICE', :local).to_sym
has_one_attached :site_favicon, service: ENV.fetch('ACTIVE_STORAGE_PUBLIC_SERVICE', :local).to_sym
enum status: [:active, :pending, :blocked] enum status: [:active, :pending, :blocked]
after_initialize :set_default_status, if: :new_record? after_initialize :set_default_status, if: :new_record?
@@ -24,12 +21,6 @@ class Tenant < ApplicationRecord
validates :subdomain, presence: true, uniqueness: true validates :subdomain, presence: true, uniqueness: true
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :custom_domain, format: { with: /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}\z/ }, uniqueness: true, allow_blank: true, allow_nil: true validates :custom_domain, format: { with: /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}\z/ }, uniqueness: true, allow_blank: true, allow_nil: true
validates :site_logo,
content_type: Rails.application.accepted_image_types,
size: { less_than: 256.kilobytes }
validates :site_favicon,
content_type: Rails.application.accepted_image_types,
size: { less_than: 64.kilobytes }
accepts_nested_attributes_for :tenant_setting, update_only: true accepts_nested_attributes_for :tenant_setting, update_only: true

View File

@@ -11,7 +11,6 @@ class User < ApplicationRecord
has_many :likes, dependent: :destroy has_many :likes, dependent: :destroy
has_many :comments, dependent: :destroy has_many :comments, dependent: :destroy
has_one :api_key, dependent: :destroy has_one :api_key, dependent: :destroy
has_one_attached :avatar, service: ENV.fetch('ACTIVE_STORAGE_PUBLIC_SERVICE', :local).to_sym
enum role: [:user, :moderator, :admin, :owner] enum role: [:user, :moderator, :admin, :owner]
enum status: [:active, :blocked, :deleted] enum status: [:active, :blocked, :deleted]
@@ -29,9 +28,6 @@ class User < ApplicationRecord
format: { with: URI::MailTo::EMAIL_REGEXP } format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, allow_blank: true, length: { in: 6..128 } validates :password, allow_blank: true, length: { in: 6..128 }
validates :password, presence: true, on: :create validates :password, presence: true, on: :create
validates :avatar,
content_type: Rails.application.accepted_image_types,
size: { less_than: 128.kilobytes }
def set_default_role def set_default_role
self.role ||= :user self.role ||= :user

View File

@@ -1,17 +1,17 @@
class CommentPolicy < ApplicationPolicy class CommentPolicy < ApplicationPolicy
def permitted_attributes_for_create def permitted_attributes_for_create
if user.moderator? if user.moderator?
[:body, :parent_id, :is_post_update, :attachments] [:body, :parent_id, :is_post_update]
else else
[:body, :parent_id, :attachments] [:body, :parent_id]
end end
end end
def permitted_attributes_for_update def permitted_attributes_for_update
if user.moderator? if user.moderator?
[:body, :is_post_update, :attachments] [:body, :is_post_update]
else else
[:body, :attachments] [:body]
end end
end end

Some files were not shown because too many files have changed in this diff Show More