diff --git a/app/assets/stylesheets/cortex-plugins-core/application.scss b/app/assets/stylesheets/cortex-plugins-core/application.scss new file mode 100644 index 0000000..a7749a2 --- /dev/null +++ b/app/assets/stylesheets/cortex-plugins-core/application.scss @@ -0,0 +1,5 @@ +// TODO: These two files should be removed once we abstract Cortex styles to a cortex-style-base lib +@import 'variables/colors'; +@import 'variables/typography'; + +@import 'components/thumbnail-placeholder'; diff --git a/app/assets/stylesheets/cortex-plugins-core/components/thumbnail-placeholder.scss b/app/assets/stylesheets/cortex-plugins-core/components/thumbnail-placeholder.scss new file mode 100644 index 0000000..4c1debe --- /dev/null +++ b/app/assets/stylesheets/cortex-plugins-core/components/thumbnail-placeholder.scss @@ -0,0 +1,13 @@ +.thumbnail-placeholder { + background-color: $color-grey-evenlighter; + display: flex; + height: 50px; + width: 50px; + + .h4 { + text-transform: uppercase; + align-self: center; + text-align: center; + width: 100%; + } +} diff --git a/app/assets/stylesheets/cortex-plugins-core/variables/_colors.scss b/app/assets/stylesheets/cortex-plugins-core/variables/_colors.scss new file mode 100644 index 0000000..f83e5d0 --- /dev/null +++ b/app/assets/stylesheets/cortex-plugins-core/variables/_colors.scss @@ -0,0 +1,47 @@ +// Color Definitions + +$color-teal: #63C0B9; +$color-teal-dark: #54A1A1; +$color-teal-light: #A1D9D5; + +$color-orange: #F79C25; +$color-orange-dark: #E78523; +$color-orange-light: #FED473; + +$color-anchor-blue-light: #747D8E; + +$color-green: #009b74; + +$color-slate-grey: #6E788F; +$color-grey: #BBB; // Type +$color-grey-dark: #333; // Type, Sidebar background, Login page background +$color-grey-light: #D4D4D4; // Content background color +$color-grey-lightest: #DDD; // Disabled and flat button background color +$color-grey-evenlighter: #E4E4E4; +$color-grey-reallylight: #F2F2F2; +$color-grey-extralight: #F4F4F4; // Wizard Instruction Panel background color + +$color-red: #d85252; + +$color-white: white; // Header +$color-black: #000000; + + +// Color Semantics + +$employer-color: $color-teal; +$employer-color-dark: $color-teal-dark; +$employer-color-light: $color-teal-light; + +$flash-error-background: $color-red; +$flash-success-background: $color-green; + +$ar-color: $color-orange; +$ar-color-dark: $color-orange-dark; +$ar-color-light: $color-orange-light; + +$notes-text: #6E788F; + +$jumbo-button-text: #6E788F; +$jumbo-button-hover-background: $color-grey-evenlighter; +$jumbo-button-active-background: $color-grey-reallylight; diff --git a/app/assets/stylesheets/cortex-plugins-core/variables/_typography.scss b/app/assets/stylesheets/cortex-plugins-core/variables/_typography.scss new file mode 100644 index 0000000..948f884 --- /dev/null +++ b/app/assets/stylesheets/cortex-plugins-core/variables/_typography.scss @@ -0,0 +1,114 @@ +@font-face { + font-family: Montserrat; + font-style: normal; + font-weight: normal; + src: url(asset_path('Montserrat-Regular.otf')) format("opentype"); +} + +@font-face { + font-family: Montserrat; + font-style: normal; + font-weight: bold; + src: url(asset_path('Montserrat-Medium.otf')) format("opentype"); +} + +@font-face { + font-family: Montserrat; + font-style: normal; + font-weight: bolder; + src: url(asset_path('Montserrat-SemiBold.otf')) format("opentype"); +} + +@font-face { + font-family: Montserrat; + font-style: normal; + font-weight: lighter; + src: url(asset_path('Montserrat-Light.otf')) format("opentype"); +} + +$cortex-font-stack: Montserrat, sans-serif; +$base-font-size: 1rem; + +%display-text { // Cortex Logo + color: $color-grey-dark; + font-family: $cortex-font-stack; + font-weight: normal; + font-size: 2.1775rem; +} + +h1, h2, h3, h4, h5, h6 { + line-height: 1.5; +} + +h1, +.text-style-1 { // Bread Crumbs and Page Headers + color: $color-teal; + font-family: $cortex-font-stack; + font-weight: bold; + font-size: 1.17rem; + text-decoration: none; +} + +h2, +.text-style-2 { + color: $color-grey; + font-family: $cortex-font-stack; + font-weight: lighter; + font-size: 1.17rem; +} + +h3, +.text-style-3 { + color: $color-grey; + font-family: $cortex-font-stack; + font-weight: bold; + font-size: 1.17rem; +} + +h4, +.text-style-4 { // Section Headers, Dropdowns, Button Text + color: $color-grey-dark; + font-family: $cortex-font-stack; + font-size: 0.83rem; +} + +h5, +.text-style-5 { // Field Text + color: $color-grey; + font-family: $cortex-font-stack; + font-weight: lighter; + font-size: $base-font-size; +} + +h6, +.text-style-6 { // Field Text Filled + color: $color-anchor-blue-light; + font-family: $cortex-font-stack; + font-size: $base-font-size; +} + +.text-style-7 { // Help Notes + color: $color-anchor-blue-light; + font-family: $cortex-font-stack; + font-weight: lighter; + font-size: 0.67rem; +} + +%sidebar-nav { // Side Nav + color: $color-grey; + font-family: $cortex-font-stack; + font-weight: lighter; + font-size: 0.875rem; + text-transform: uppercase; + + &.active { // Side Nav Selected + font-weight: bold; + color: $color-teal; + } +} + +p { + color: $color-anchor-blue-light; + font-family: $cortex-font-stack; + font-size: $base-font-size; +} diff --git a/app/cells/plugins/core/asset_cell.rb b/app/cells/plugins/core/asset_cell.rb index c9daed1..a15101a 100644 --- a/app/cells/plugins/core/asset_cell.rb +++ b/app/cells/plugins/core/asset_cell.rb @@ -19,8 +19,12 @@ def render_allowed_asset_extensions field.validations['allowed_extensions']&.join(', ') end + def allowed_asset_extensions_for_form + '.' + field.validations['allowed_extensions']&.join(',.') + end + def render_max_asset_size - number_to_human_size(field.validations['size']&.[]('less_than')) + number_to_human_size(field.validations['max_size']) end def input_classes @@ -36,7 +40,7 @@ def render_label end def render_input - @options[:form].file_field 'data[asset]' + @options[:form].file_field 'data[asset]', accept: allowed_asset_extensions_for_form end def render_tooltip @@ -44,7 +48,8 @@ def render_tooltip end def associated_content_item_thumb_url - data['asset']['style_urls']['mini'] + # TODO: The thumb version needs to be configurable + data['asset']['versions']['mini']['url'] end def render_associated_content_item_thumb diff --git a/app/cells/plugins/core/asset_info/index.haml b/app/cells/plugins/core/asset_info/index.haml index e24b916..27e86e5 100644 --- a/app/cells/plugins/core/asset_info/index.haml +++ b/app/cells/plugins/core/asset_info/index.haml @@ -1,2 +1,6 @@ -- if asset - = image_tag(asset['style_urls'][config[:thumbnail_style]], height: '50px') +- if asset && asset['versions'][config[:thumbnail_style]] + = image_tag(asset['versions'][config[:thumbnail_style]]['url'], height: '50px') +- else + .thumbnail-placeholder + .h4 + = asset['versions']['original']['extension'] diff --git a/app/cells/plugins/core/asset_info/show.haml b/app/cells/plugins/core/asset_info/show.haml index 450a352..ad1eae0 100644 --- a/app/cells/plugins/core/asset_info/show.haml +++ b/app/cells/plugins/core/asset_info/show.haml @@ -4,24 +4,26 @@ new Clipboard('#copy-asset-url'); }); - .asset-info.mdl-card.mdl-shadow--2dp - .mdl-card__title - %h2.mdl-card__title-text - = image_tag(asset['url'], style: 'max-height: 200px;') + .asset-info.mdl-card + - if asset_is_image? + .mdl-card__title + %h2.mdl-card__title-text + = image_tag(asset['versions']['original']['url'], style: 'max-height: 200px;') .mdl-card__supporting-text %dl %dt Original Filename %dd - = asset['file_name'] - %dt File Type + = asset['original_filename'] + %dt Original File Type %dd - = asset['content_type'] - %dt File Size + = asset['versions']['original']['mime_type'] + %dt Original File Size %dd - = number_to_human_size(asset['file_size']) - %dt Dimensions - %dd - = dimensions + = number_to_human_size(asset['versions']['original']['file_size']) + - if asset_is_image? + %dt Original Dimensions + %dd + = dimensions %dt Creator %dd = creator.fullname @@ -35,7 +37,7 @@ %dd = link_to_asset .mdl-card__menu - .mdl-button.mdl-button--icon.mdl-js-button.mdl-js-ripple-effect#copy-asset-url{data: {'clipboard-text': asset['url']}} + .mdl-button.mdl-button--icon.mdl-js-button.mdl-js-ripple-effect#copy-asset-url{data: {'clipboard-text': asset['versions']['original']['url']}} %i.material-icons content_copy .mdl-tooltip{for: 'copy-asset-url'} Copy Asset URL diff --git a/app/cells/plugins/core/asset_info_cell.rb b/app/cells/plugins/core/asset_info_cell.rb index ab06633..e5bacc9 100644 --- a/app/cells/plugins/core/asset_info_cell.rb +++ b/app/cells/plugins/core/asset_info_cell.rb @@ -25,7 +25,7 @@ def asset end def dimensions - "#{asset['dimensions']['width']} x #{asset['dimensions']['width']}" + "#{asset['versions']['original']['dimensions']['width']} x #{asset['versions']['original']['dimensions']['width']}" end def creator @@ -37,11 +37,19 @@ def created_at end def updated_at - DateTime.parse(asset['updated_at']).to_formatted_s(:long_ordinal) + content_item.updated_at.to_formatted_s(:long_ordinal) end def link_to_asset - link_to asset['url'], asset['url'], target: '_blank' + link_to asset['versions']['original']['url'], asset['versions']['original']['url'], target: '_blank' + end + + def asset_type + MimeMagic.new(asset['versions']['original']['mime_type']).mediatype + end + + def asset_is_image? + asset_type == 'image' end end end diff --git a/app/models/asset_field_type.rb b/app/models/asset_field_type.rb index c363d6b..0de03a1 100644 --- a/app/models/asset_field_type.rb +++ b/app/models/asset_field_type.rb @@ -1,139 +1,124 @@ +require 'shrine/storage/s3' + class AssetFieldType < FieldType - attr_accessor :asset_file_name, - :asset_content_type, - :asset_file_size, - :asset_updated_at, - :asset - - attr_reader :dimensions, - :existing_data - - before_save :extract_dimensions - - do_not_validate_attachment_file_type :asset - validates :asset, attachment_presence: true, if: :validate_presence? - validate :validate_asset_size, if: :validate_size? - validate :validate_asset_content_type, if: :validate_content_type? - - def metadata=(metadata_hash) - @metadata = metadata_hash.deep_symbolize_keys - @existing_data = metadata_hash[:existing_data] - Paperclip::HasAttachedFile.define_on(self.class, :asset, existing_metadata) - end + attr_reader :asset + attr_accessor :asset_data + + before_save :promote + + validate :asset_presence, if: :validate_presence? + validate :asset_errors def data=(data_hash) - self.asset = data_hash.deep_symbolize_keys[:asset] + assign data_hash['asset'] if data_hash['asset'] + @asset = attacher.get end def data + return {} if errors.any? || attacher.errors.any? { - 'asset': { - 'file_name': asset_file_name, - 'url': asset.url, - 'style_urls': style_urls, - 'dimensions': dimensions, - 'content_type': asset_content_type, - 'file_size': asset_file_size, - 'updated_at': asset_updated_at + asset: { + original_filename: @original_filename, + # TODO: updated_at: asset.updated_at, -- Does Shrine give this to us? Potentially distinct from record's updated_at + versions: versions_data }, - 'media_title': media_title, - 'asset_field_type_id': id + shrine_asset: asset.to_json } end def field_item_as_indexed_json_for_field_type(field_item, options = {}) json = {} - json[mapping_field_name] = asset_file_name + json[mapping_field_name] = field_item.data['asset']['original_filename'] json end def mapping - {name: mapping_field_name, type: :string, analyzer: :keyword} + { name: mapping_field_name, type: :string, analyzer: :keyword } end private def image? - asset_content_type =~ %r{^(image|(x-)?application)/(bmp|gif|jpeg|jpg|pjpeg|png|x-png)$} + MimeMagic.new(asset.mime_type).mediatype == 'image' end - def extract_dimensions - return unless image? - tempfile = asset.queued_for_write[:original] - unless tempfile.nil? - geometry = Paperclip::Geometry.from_file(tempfile) - @dimensions = { - width: geometry.width.to_i, - height: geometry.height.to_i - } - end + def mapping_field_name + "#{field_name.parameterize('_')}_asset_file_name" end - def allowed_content_types - validations[:allowed_extensions].collect do |allowed_content_type| - MimeMagic.by_extension(allowed_content_type).type - end + def promote + @asset = attacher.promote action: :store unless asset.is_a?(Hash) end - def media_title - existing_data['media_title'] || ContentItemService.form_fields[@metadata[:naming_data][:title]][:text].parameterize.underscore - end + def assign(attachment) + @original_filename = attachment.original_filename - def mapping_field_name - "#{field_name.parameterize('_')}_asset_file_name" + attachment.open + begin + attacher.assign attachment + ensure + attachment.close + end end - def validate_presence? - validations.key? :presence + def store + case metadata[:storage][:type] + when 's3' + Shrine::Storage::S3.new(metadata[:storage][:config]) # TODO: Encrypt credentials? + when 'file_system' + Shrine::Storage::FileSystem.new(metadata[:storage][:config]) + else + AssetUploader.storages[:store] + end end - def attachment_size_validator - AttachmentSizeValidator.new(validations[:size].merge(attributes: :asset)) - end + def attacher + unless @attacher + AssetUploader.storages[:store_copy] = store # this may not be thread safe, but no other way to do this right now + AssetUploader.opts[:keep_files] = metadata[:keep_files] # this may not be thread safe, but no other way to do this right now + @attacher = AssetUploader::Attacher.new self, :asset, store: :store_copy + @attacher.context[:config] = { + original_filename: @original_filename, + metadata: metadata, + validations: validations + } + end - def attachment_content_type_validator - AttachmentContentTypeValidator.new({content_type: allowed_content_types}.merge(attributes: :asset)) + @attacher end - alias_method :valid_presence_validation?, :validate_presence? - - def validate_size? - begin - attachment_size_validator - true - rescue ArgumentError, NoMethodError - false - end + def host_alias + metadata[:storage][:host_alias] unless metadata[:storage][:host_alias].empty? end - def validate_content_type? - begin - attachment_content_type_validator - true - rescue ArgumentError, NoMethodError - false + def versions_data + asset.transform_values do |version| + { + id: version.id, + filename: version.metadata['filename'], + extension: version.extension, + mime_type: version.mime_type, + url: version.url(public: true, host: host_alias), + file_size: version.size, + dimensions: { + width: version.width, + height: version.height + } + } end end - def validate_asset_size - attachment_size_validator.validate_each(self, :asset, asset) + def validate_presence? + validations.key? :presence end - def validate_asset_content_type - attachment_content_type_validator.validate_each(self, :asset, asset) + def asset_presence + errors.add(:asset, 'must be present') unless asset end - def style_urls - if existing_data.empty? - (metadata[:styles].map { |key, value| [key, asset.url(key)] }).to_h - else - existing_data.deep_symbolize_keys[:asset][:style_urls] + def asset_errors + attacher.errors.each do |message| + errors.add(:asset, message) end end - - def existing_metadata - metadata.except!(:existing_data) - metadata[:path].gsub!(":media_title", media_title) if metadata[:path] - metadata - end end diff --git a/app/models/text_field_type.rb b/app/models/text_field_type.rb index 0bbcbdc..64364a8 100644 --- a/app/models/text_field_type.rb +++ b/app/models/text_field_type.rb @@ -26,16 +26,13 @@ def mapping_field_name "#{field_name.parameterize('_')}_text" end - def text_present - errors.add(:text, 'must be present') if @text.empty? - end - def text_length validator = LengthValidator.new(validations[:length].merge(attributes: [:text])) validator.validate_each(self, :text, text) end def text_unique + # TODO: This breaks when you try to update existing text unless metadata[:existing_data][:text] == text || field.field_items.jsonb_contains(:data, text: text).empty? errors.add(:text, "#{field.name} Must be unique") end diff --git a/app/uploaders/asset_uploader.rb b/app/uploaders/asset_uploader.rb new file mode 100644 index 0000000..e287cbc --- /dev/null +++ b/app/uploaders/asset_uploader.rb @@ -0,0 +1,55 @@ +require 'image_processing/mini_magick' + +class AssetUploader < Shrine + include ImageProcessing::MiniMagick + + plugin :determine_mime_type + plugin :store_dimensions + plugin :validation_helpers + plugin :cortex_validation_helpers + plugin :processing + plugin :versions + plugin :keep_files, destroyed: true, replaced: true + + Attacher.validate do + validate_mime_type_inclusion allowed_content_types if validate? :allowed_extensions + validate_max_size validations[:max_size] if validate? :max_size + validate_min_size validations[:min_size] if validate? :min_size + + if store.image?(get) + validate_max_width validations[:max_width] if validate? :max_width + validate_max_height validations[:max_height] if validate? :max_height + validate_min_width validations[:min_width] if validate? :min_width + validate_min_height validations[:min_height] if validate? :min_height + end + end + + process(:store) do |io, context| + # TODO: Perform image optimizations (build plugin), support versions without processors or formatters + context[:generated_hex] = SecureRandom.hex(8) + versions = { original: io.download } + + if image?(io) + versions.merge!(context[:config][:metadata][:versions].transform_values do |version| + processed_version = send("#{version[:process][:method]}!", io.download, *version[:process][:config].values) + convert!(processed_version, version[:format]) + end) + end + + versions + end + + def generate_location(io, context) + attachment = :asset + style = context[:version] || :original + original_name, _dot, original_extension = context[:config][:original_filename].rpartition('.') + generated_name, _dot, extension = super.rpartition('.') + generated_hex = context[:generated_hex] + + ERB.new(context[:config][:metadata][:path]).result(binding) + end + + def image?(io) + MimeMagic.new(io.data['metadata']['mime_type']).mediatype == 'image' + end +end diff --git a/config/initializers/shrine.rb b/config/initializers/shrine.rb new file mode 100644 index 0000000..9ad84af --- /dev/null +++ b/config/initializers/shrine.rb @@ -0,0 +1,9 @@ +require 'shrine' +require 'shrine/storage/file_system' + +Shrine.storages = { + cache: Shrine::Storage::FileSystem.new('public', prefix: 'uploads/cache'), + store: Shrine::Storage::FileSystem.new('public', prefix: 'uploads/store') +} + +Shrine.plugin :logging, logger: Rails.logger diff --git a/cortex-plugins-core.gemspec b/cortex-plugins-core.gemspec index f826f64..4a2040d 100644 --- a/cortex-plugins-core.gemspec +++ b/cortex-plugins-core.gemspec @@ -19,8 +19,14 @@ Gem::Specification.new do |s| s.add_dependency "rails", ">= 4" s.add_dependency "react_on_rails", "~> 6" s.add_dependency "cells", "~> 4.1" - s.add_dependency "cells-rails", "~> 0.0.6" - s.add_dependency "cells-haml", "~> 0.0.10" - s.add_dependency "mimemagic", "~> 0.3.2" + s.add_dependency "cells-rails", "~> 0.0" + s.add_dependency "cells-haml", "~> 0.0" s.add_dependency "jsonb_accessor", "~> 1.0.0.beta" + + # AssetFieldType + s.add_dependency "shrine", "~> 2.6" + s.add_dependency "mimemagic", "~> 0.3" + s.add_dependency "image_processing", "~> 0.4" + s.add_dependency "mini_magick", "~> 4.7" + s.add_dependency "fastimage", "~> 2.1" end diff --git a/lib/cortex/plugins/core/version.rb b/lib/cortex/plugins/core/version.rb index cb1561e..21c0511 100644 --- a/lib/cortex/plugins/core/version.rb +++ b/lib/cortex/plugins/core/version.rb @@ -1,7 +1,7 @@ module Cortex module Plugins module Core - VERSION = '0.11.3' + VERSION = '0.12.1' end end end diff --git a/lib/shrine/plugins/cortex_validation_helpers.rb b/lib/shrine/plugins/cortex_validation_helpers.rb new file mode 100644 index 0000000..a8d6137 --- /dev/null +++ b/lib/shrine/plugins/cortex_validation_helpers.rb @@ -0,0 +1,23 @@ +class Shrine + module Plugins + module CortexValidationHelpers + module AttacherMethods + def validations + context[:config][:validations] + end + + def validate?(validation) + validations.key? validation + end + + def allowed_content_types + validations[:allowed_extensions].collect do |allowed_content_type| + MimeMagic.by_extension(allowed_content_type).type + end + end + end + end + + register_plugin(:cortex_validation_helpers, CortexValidationHelpers) + end +end diff --git a/lib/tasks/cortex/core/media.rake b/lib/tasks/cortex/core/media.rake index 225d378..8316fe6 100644 --- a/lib/tasks/cortex/core/media.rake +++ b/lib/tasks/cortex/core/media.rake @@ -18,34 +18,43 @@ namespace :cortex do puts "Creating Fields..." allowed_asset_content_types = %w(txt css js pdf doc docx ppt pptx csv xls xlsx svg ico png jpg gif bmp) - fieldTitle = media.fields.new(name: 'Title', field_type: 'text_field_type', validations: {presence: true, uniqueness: true}) + fieldTitle = media.fields.new(name: 'Title', field_type: 'text_field_type', validations: { presence: true, uniqueness: true }) fieldTitle.save media.fields.new(name: 'Asset', field_type: 'asset_field_type', validations: { presence: true, allowed_extensions: allowed_asset_content_types, - size: { - less_than: 50.megabytes - } + max_size: 50.megabytes }, metadata: { naming_data: { title: fieldTitle.id }, - styles: { - large: {geometry: '1800x1800>', format: :jpg}, - medium: {geometry: '800x800>', format: :jpg}, - default: {geometry: '300x300>', format: :jpg}, - mini: {geometry: '100x100>', format: :jpg}, - micro: {geometry: '50x50>', format: :jpg}, - post_tile: {geometry: '1140x', format: :jpg} + versions: { + large: { process: { method: 'resize_to_limit', config: { width: '1800', height: '1800' } }, format: :jpg }, + medium: { process: { method: 'resize_to_limit', config: { width: '800', height: '800' } }, format: :jpg }, + default: { process: { method: 'resize_to_limit', config: { width: '300', height: '300' } }, format: :jpg }, + mini: { process: { method: 'resize_to_limit', config: { width: '100', height: '100' } }, format: :jpg }, + micro: { process: { method: 'resize_to_limit', config: { width: '50', height: '50' } }, format: :jpg }, }, - processors: [:thumbnail, :paperclip_optimizer], - preserve_files: true, - path: ':class/:attachment/:media_title-:style.:extension', - s3_headers: {'Cache-Control': 'public, max-age=315576000'} + keep_files: [:destroyed, :replaced], + path: '<%= attachment %>/<%= original_name %>-<%= style %>-<%= generated_hex %>.<%= extension %>', + storage: { + type: 's3', + host_alias: ENV['HOST_ALIAS'], + config: { + access_key_id: ENV['S3_ACCESS_KEY_ID'], + secret_access_key: ENV['S3_SECRET_ACCESS_KEY'], + region: ENV['S3_REGION'], + bucket: ENV['S3_BUCKET_NAME'], + upload_options: { + acl: 'public-read', + cache_control: 'public, max-age=315576000' + } + } + } }) media.fields.new(name: 'Description', field_type: 'text_field_type', validations: {presence: true}) media.fields.new(name: 'Tags', field_type: 'tag_field_type')