diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000000..f8497f0f21 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,3 @@ +# Cursor Rules + +Read and apply instructions from AGENTS.md at the project root. diff --git a/.tool-versions b/.tool-versions index 0d2a2f900f..77d2debd42 100644 --- a/.tool-versions +++ b/.tool-versions @@ -2,3 +2,4 @@ # something like RVM for anything, not just ruby ruby 3.3.3 # nodejs 14.15.1 +nodejs 20 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..de91340bc1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,71 @@ +# Agent Instructions + +## AGENTS.md File Resolution + +AGENTS.md files can be placed at the project root or in subdirectories. When multiple files exist, traverse up from the edited file's directory to the root, collecting all AGENTS.md files. Files closer to the edited file take precedence over files further away. + +**Example:** Files in the `tiptap/` directory use `tiptap/AGENTS.md` which overrides the JavaScript formatting/linting instructions from the root `AGENTS.md` (using eslint/prettier instead of standardjs). + +## Code Formatting and Linting + +After editing any code files, automatically format and lint them using the appropriate tools for that language. + +### Bash/Shell +- Format: `shfmt -w ` +- Lint: `shellcheck ` + +### Ruby/Ruby on Rails +- Format & Lint: `bundle exec rubocop --autocorrect-all ` +- Note: Guard automatically runs rubocop on Ruby files when they change. For manual runs, use the command above. + +### JavaScript +- Format & Lint: `npx standard --fix ` +- Note: Guard automatically runs standardjs on JavaScript files when they change. For manual runs, use the command above. + +### Slim +- Lint: `slim-lint ` +- Note: Guard automatically runs slimlint on Slim files when they change. For manual runs, use the command above. + +## Rails Best Practices + +- Use TDD (write tests first) +- Use early returns to reduce nesting +- Keep methods focused and under ~20 lines +- Use namespaces for entire functionality (all related models, controllers, components together) + +## View Components + +- Always use the component generator: `rails generate folio:component blog/post` generates `MyApp::Blog::PostComponent` +- Use BEM methodology for CSS class names + - Block (B) is generated from component class name - takes first letter (lowercase) of top-level namespace + rest of namespace path in kebab-case + - Example: `MyApp::Blog::PostComponent` → Block is `"m-blog-post"` + - Special case: `Folio::Console::` → `f-c` + - Elements (E) and Modifiers (M) follow standard BEM: `__element` and `--modifier` + - Example: `m-blog-post__button` (element), `m-blog-post__button--active` (modifier) +- Stimulus: Use `stimulus_controller("controller-name", values: {...}, action: {...}, classes: [...])` helper for JavaScript behavior +- See [docs/components.md](docs/components.md) for detailed component guidelines + +## File Formatting Standards + +When editing any file: +- Remove trailing whitespace from all lines +- Keep a single newline at the end of file (EOF) + +## Git Commits + +All commits must use semantic commit messages: + +``` +(): + + +``` + +**Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` + +**Examples:** +- `feat(nested_fields): add sortable auto scroll` +- `chore(react): standardjs lint` +- `docs(tiptap): add early returns preference to AGENTS.md` + +Scope is optional but recommended for clarity. Describe the final state/outcome, not the implementation steps. Keep the message concise and focused on what was achieved. diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f69db188..90e763ecff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to this project will be documented in this file. - added `disabled_modifications` option to `form_footer` - added `calendar_filter` icon - added `thumbnail_configuration` to `Folio::File` to store crop data per-ratio, set via a new `Folio::Console::Files::Show::Thumbnails::CropEditComponent` and `update_thumbnails_crop` API +- support for MOV (video/quicktime) files ### Changed @@ -42,6 +43,7 @@ All notable changes to this project will be documented in this file. ### Fixed - file_list/file_component info-file-name doesn't break on long names +- instagram embeds from url loading with small width ## [6.5.1] - 2025-06-18 diff --git a/app/assets/stylesheets/folio/tiptap/_styles.scss b/app/assets/stylesheets/folio/tiptap/_styles.scss index 2b440d591a..97a6187132 100644 --- a/app/assets/stylesheets/folio/tiptap/_styles.scss +++ b/app/assets/stylesheets/folio/tiptap/_styles.scss @@ -3,7 +3,7 @@ $f-tiptap__spacer: 1rem !default; $f-tiptap__media-min-width--tablet: 576px !default; $f-tiptap__media-min-width--desktop: 708px !default; -$f-tiptap-root__background-color: transparent !default; +$f-tiptap-root__background-color: var(--bs-body-bg, #ffffff) !default; $f-tiptap-root__color: inherit !default; $f-tiptap-root__font-family: inherit !default; $f-tiptap-root__font-size: inherit !default; @@ -487,6 +487,10 @@ $f-tiptap-table-td__min-width: 5rem !default; } .f-tiptap-float { + // Default values for CSS custom properties (below 576px, float layout is not active) + --f-tiptap-float__aside-width: 0; + --f-tiptap-float__aside-margin-x: 0; + --f-tiptap-float__aside-offset: 0; position: relative; } @@ -517,6 +521,25 @@ $f-tiptap-table-td__min-width: 5rem !default; background-color: $f-tiptap-float__aside-background-color-in-editor-only; } + // Force two-line placeholder for empty paragraphs that are only child of [data-node-view-content-react] + // Get numeric line-height value for multiline placeholder - use variable if numeric, otherwise default to 1.5 + $multiline-placeholder-line-height: if( + type-of($f-tiptap-root__line-height) == "number", + $f-tiptap-root__line-height, + 1.5 + ); + + .f-tiptap-float__aside, + .f-tiptap-float__main { + [data-node-view-content-react] > p.is-empty:only-child, + [data-node-view-content-react] > p:has(.ProseMirror-trailingBreak):only-child { + // Use 2lh (line-height unit) - represents 2 line-heights of the element + // Fallback for browsers that don't support lh unit, using calculated numeric value + min-height: calc(2 * 1em * #{$multiline-placeholder-line-height}); + min-height: 2lh; // Modern browsers: 2 line-heights + } + } + .f-tiptap-styled-paragraph { @each $variant, $map in $f-tiptap__styled-paragraph-variants { &[data-f-tiptap-styled-paragraph-variant="#{$variant}"] { @@ -644,6 +667,12 @@ $f-tiptap-table-td__min-width: 5rem !default; --f-tiptap-hr__margin: #{$f-tiptap-hr__margin--tablet}; .f-tiptap-float { + // Default aside width (medium) + --f-tiptap-float__aside-width: #{$f-tiptap-float__aside-width-medium}; + --f-tiptap-float__aside-side: left; + --f-tiptap-float__aside-margin-x: #{$f-tiptap-float__aside-margin-x--tablet}; + --f-tiptap-float__aside-offset: #{$f-tiptap-float__aside-offset--tablet}; + &::after { content: ""; clear: both; @@ -651,6 +680,18 @@ $f-tiptap-table-td__min-width: 5rem !default; } } + .f-tiptap-float[data-f-tiptap-float-size="small"] { + --f-tiptap-float__aside-width: #{$f-tiptap-float__aside-width-small}; + } + + .f-tiptap-float[data-f-tiptap-float-size="large"] { + --f-tiptap-float__aside-width: #{$f-tiptap-float__aside-width-large}; + } + + .f-tiptap-float[data-f-tiptap-float-side="right"] { + --f-tiptap-float__aside-side: right; + } + .f-tiptap-float__aside { float: left; margin-right: $f-tiptap-float__aside-margin-x--tablet; @@ -727,6 +768,11 @@ $f-tiptap-table-td__min-width: 5rem !default; gap: $f-tiptap-columns__gap--desktop; } + .f-tiptap-float { + --f-tiptap-float__aside-margin-x: #{$f-tiptap-float__aside-margin-x--desktop}; + --f-tiptap-float__aside-offset: #{$f-tiptap-float__aside-offset--desktop}; + } + .f-tiptap-float__aside { margin-right: $f-tiptap-float__aside-margin-x--desktop; margin-bottom: $f-tiptap-float__aside-margin-y--desktop; diff --git a/app/components/folio/player_component.js b/app/components/folio/player_component.js index d3d8984419..174e7add8c 100644 --- a/app/components/folio/player_component.js +++ b/app/components/folio/player_component.js @@ -164,7 +164,12 @@ window.Folio.Player.innerBind = (el, opts, file) => { child.muted = false const source = document.createElement('source') - source.type = fileAttributes.player_source_mime_type + // Map video/quicktime to video/mp4 for browser compatibility + // MOV files often contain H.264 codec which browsers can play as MP4 + const mimeType = fileAttributes.player_source_mime_type === 'video/quicktime' + ? 'video/mp4' + : fileAttributes.player_source_mime_type + source.type = mimeType source.src = fileAttributes.mux_source_url || fileAttributes.source_url child.appendChild(source) diff --git a/app/components/folio/uppy_component.js b/app/components/folio/uppy_component.js index 7e751d6d25..a9ac72525a 100644 --- a/app/components/folio/uppy_component.js +++ b/app/components/folio/uppy_component.js @@ -70,7 +70,14 @@ window.Folio.Stimulus.register('f-uppy', class extends window.Stimulus.Controlle } if (this.allowedFormatsValue) { - restrictions.allowedFileTypes = this.allowedFormatsValue.split(',') + const allowedTypes = this.allowedFormatsValue.split(',').map(type => type.trim()) + + // Add .mov extension if video/quicktime is allowed + if (allowedTypes.includes('video/quicktime') && !allowedTypes.includes('.mov')) { + allowedTypes.push('.mov') + } + + restrictions.allowedFileTypes = allowedTypes } if (this.maxFileSizeValue) { @@ -94,7 +101,7 @@ window.Folio.Stimulus.register('f-uppy', class extends window.Stimulus.Controlle if (this.allowedFormatsValue) { const formattedFormats = this.allowedFormatsValue.split(',') - .map(format => format.trim().toUpperCase().split('/', 2).pop().replace('SVG+XML', 'SVG')) + .map(format => format.trim().toUpperCase().split('/', 2).pop().replace('SVG+XML', 'SVG').replace('QUICKTIME', 'MOV')) .join(', ') const supportedFormatsLabel = window.Folio.i18n(this.constructor.ERROR_MESSAGES, 'supportedFormats') diff --git a/app/lib/folio/tiptap/config.rb b/app/lib/folio/tiptap/config.rb index 2f0408efcb..d0aa362d77 100644 --- a/app/lib/folio/tiptap/config.rb +++ b/app/lib/folio/tiptap/config.rb @@ -10,7 +10,8 @@ class Config :pages_component_class_name, :heading_levels, :autosave, - :embed_node_class_name + :embed_node_class_name, + :toolbar_groups def initialize(node_names: nil, styled_paragraph_variants: nil, @@ -19,7 +20,8 @@ def initialize(node_names: nil, pages_component_class_name: nil, heading_levels: nil, autosave: true, - embed_node_class_name: nil) + embed_node_class_name: nil, + toolbar_groups: nil) @node_names = node_names || get_all_tiptap_node_names @styled_paragraph_variants = styled_paragraph_variants || default_styled_paragraph_variants @styled_wrap_variants = styled_wrap_variants || default_styled_wrap_variants @@ -27,6 +29,7 @@ def initialize(node_names: nil, @heading_levels = heading_levels || default_heading_levels @autosave = autosave @embed_node_class_name = embed_node_class_name + @toolbar_groups = toolbar_groups || [] @schema = schema || build_default_schema end @@ -39,7 +42,8 @@ def to_h heading_levels: @heading_levels, pages_component_class_name: @pages_component_class_name, autosave: @autosave, - embed_node_class_name: @embed_node_class_name + embed_node_class_name: @embed_node_class_name, + toolbar_groups: @toolbar_groups } end @@ -48,6 +52,7 @@ def to_input_json h[:nodes] = tiptap_nodes_hash(@node_names) h[:enable_pages] = @pages_component_class_name.present? + h[:toolbar_groups] = @toolbar_groups if @toolbar_groups.present? h.to_json end diff --git a/app/lib/folio/tiptap/node_builder.rb b/app/lib/folio/tiptap/node_builder.rb index 42f7aecfdb..154a2a5dbb 100644 --- a/app/lib/folio/tiptap/node_builder.rb +++ b/app/lib/folio/tiptap/node_builder.rb @@ -515,7 +515,7 @@ def convert_structure_to_hashes(structure) # Whitelist of allowed keys and their value types in tiptap_config hash, hashes cannot include keys not listed here. TIPTAP_CONFIG_HASH_WHITELIST = { - toolbar: { icon: String, slot: String }, + toolbar: { icon: String, slot: String, dropdown_group: String }, autoclick_cover: [TrueClass, FalseClass], } @@ -531,8 +531,15 @@ def get_tiptap_config(tiptap_config_hash_or_nil) end if TIPTAP_CONFIG_HASH_WHITELIST[key].is_a?(Hash) - unless TIPTAP_CONFIG_HASH_WHITELIST[key].all? { |k, klass| value[k].is_a?(klass) } - raise ArgumentError, "Expected value for `#{key}` in tiptap_config to be a Hash with keys #{TIPTAP_CONFIG_HASH_WHITELIST[key].map { |k, v| "#{k}: #{v}" }.join(', ')}, got #{value.inspect}" + # Validate each provided key, but allow optional keys to be missing + value.each do |k, v| + expected_klass = TIPTAP_CONFIG_HASH_WHITELIST[key][k] + if expected_klass.nil? + raise ArgumentError, "Unknown key `#{k}` in tiptap_config[:#{key}]. Allowed keys are: #{TIPTAP_CONFIG_HASH_WHITELIST[key].keys.join(', ')}" + end + unless v.is_a?(expected_klass) + raise ArgumentError, "Expected value for `#{key}[:#{k}]` in tiptap_config to be of type #{expected_klass}, got #{v.class.name}" + end end else unless TIPTAP_CONFIG_HASH_WHITELIST[key].any? { |klass| value.is_a?(klass) } diff --git a/app/models/concerns/folio/has_attachments.rb b/app/models/concerns/folio/has_attachments.rb index 23a0d6ca71..3184c200b8 100644 --- a/app/models/concerns/folio/has_attachments.rb +++ b/app/models/concerns/folio/has_attachments.rb @@ -331,8 +331,11 @@ def validate_file_placements_if_needed end end + # Filter out placements marked for destruction - they will be removed anyway + placements_to_validate = all_placements_ary.reject(&:marked_for_destruction?) + if should_validate_file_placements_attribution_if_needed? - all_placements_ary.each do |placement| + placements_to_validate.each do |placement| placement.validate_attribution_if_needed if placement.errors[:file].present? has_invalid_file_placements = true @@ -341,7 +344,7 @@ def validate_file_placements_if_needed end if should_validate_file_placements_alt_if_needed? - all_placements_ary.each do |placement| + placements_to_validate.each do |placement| placement.validate_alt_if_needed if placement.errors[:file].present? has_invalid_file_placements = true @@ -350,7 +353,7 @@ def validate_file_placements_if_needed end if should_validate_file_placements_description_if_needed? - all_placements_ary.each do |placement| + placements_to_validate.each do |placement| placement.validate_description_if_needed if placement.errors[:file].present? has_invalid_file_placements = true @@ -408,30 +411,12 @@ def validate_files_usage_limits_if_publishing end def get_files_with_usage_constraints - # Get unique file types that have HasUsageConstraints concern - file_types_with_constraints = Folio::FilePlacement::Base - .joins("INNER JOIN folio_files ON folio_files.id = folio_file_placements.file_id") - .where(placement_id: id, placement_type: self.class.base_class.name) - .distinct - .pluck("folio_files.type") - .compact - .select { |type| type.constantize.included_modules.include?(Folio::File::HasUsageConstraints) } - .uniq - - return [] if file_types_with_constraints.empty? - - file_ids = Folio::File - .joins(:file_placements) - .where( - type: file_types_with_constraints, - file_placements: { - placement_id: id, - placement_type: self.class.base_class.name - } - ) - .distinct - .pluck(:id) - - Folio::File.where(id: file_ids) + # Collect placements from in-memory associations to respect unsaved changes from nested attributes + placements = collect_all_placements.reject(&:marked_for_destruction?) + + # Get files from placements that have HasUsageConstraints concern + files = placements.filter_map(&:file).compact.uniq + + files.select { |file| file.class.included_modules.include?(Folio::File::HasUsageConstraints) } end end diff --git a/app/models/folio/file/video.rb b/app/models/folio/file/video.rb index 6812fb5c5a..492e916b50 100644 --- a/app/models/folio/file/video.rb +++ b/app/models/folio/file/video.rb @@ -3,7 +3,7 @@ class Folio::File::Video < Folio::File include Folio::File::Video::HasSubtitles - validate_file_format %w[video/mp4 video/webm] + validate_file_format %w[video/mp4 video/webm video/quicktime] def console_show_additional_fields additional_fields = {} diff --git a/app/services/folio/console/files/batch_service.rb b/app/services/folio/console/files/batch_service.rb index aa1c2d1b4f..866b7131d5 100644 --- a/app/services/folio/console/files/batch_service.rb +++ b/app/services/folio/console/files/batch_service.rb @@ -128,12 +128,49 @@ def download_key end def redis_client - @redis_client ||= Redis.new( - url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"), - timeout: ENV.fetch("REDIS_TIMEOUT", 5).to_i, - reconnect_attempts: ENV.fetch("REDIS_RECONNECT_ATTEMPTS", 3).to_i, - reconnect_delay: ENV.fetch("REDIS_RECONNECT_DELAY", 0.5).to_f, - reconnect_delay_max: ENV.fetch("REDIS_RECONNECT_DELAY_MAX", 5.0).to_f, - ) + @redis_client ||= begin + # Build base Redis configuration + redis_options = { + url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"), + timeout: ENV.fetch("REDIS_TIMEOUT", 5).to_i, + reconnect_attempts: ENV.fetch("REDIS_RECONNECT_ATTEMPTS", 3).to_i, + } + + # Add reconnect_delay parameters only if redis-client supports them + # redis-client >= 0.23 removed support for reconnect_delay/reconnect_delay_max + if redis_client_supports_reconnect_delay? + reconnect_delay = ENV.fetch("REDIS_RECONNECT_DELAY", 0.5).to_f + reconnect_delay_max = ENV.fetch("REDIS_RECONNECT_DELAY_MAX", 5.0).to_f + redis_options.merge!( + reconnect_delay: reconnect_delay, + reconnect_delay_max: reconnect_delay_max, + ) + end + + Redis.new(redis_options) + end + end + + # Check if the current redis-client version supports reconnect_delay parameters + # Versions >= 0.23 removed support for these parameters + def redis_client_supports_reconnect_delay? + return false unless defined?(RedisClient) + + # Check version if available (most reliable method) + if RedisClient.const_defined?(:VERSION) + version = Gem::Version.new(RedisClient::VERSION) + # reconnect_delay was removed in redis-client 0.23+ + return version < Gem::Version.new("0.23.0") + end + + # Fallback: check method signature if version is not available + begin + config_class = RedisClient::Config + method_params = config_class.instance_method(:initialize).parameters.map { |_, name| name } + return method_params.include?(:reconnect_delay) + rescue + # If we can't inspect, assume newer version without support + false + end end end diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb new file mode 100644 index 0000000000..9b96078980 --- /dev/null +++ b/config/initializers/mime_types.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +Mime::Type.register "image/avif", :avif +Mime::Type.register "video/quicktime", :mov diff --git a/data/embed/dist/folio-embed-dist.html b/data/embed/dist/folio-embed-dist.html index ffdc60d3fd..aef04ac3ed 100644 --- a/data/embed/dist/folio-embed-dist.html +++ b/data/embed/dist/folio-embed-dist.html @@ -51,7 +51,8 @@ display: block; } -.f-embed__container--instagram .instagram-media-rendered ~ .f-embed__loader, +.instagram-media-rendered ~ .f-embed__loader, +.twitter-tweet-rendered ~ .f-embed__loader, .f-embed__container--twitter .twitter-tweet:not(.twitter-tweet-rendered) { display: none; } @@ -140,6 +141,12 @@ .f-embed__container--twitter .f-embed__loader:not(:last-child) { display: none; } + +.f-embed__blockquote--instagram { + max-width: 540px; + width: -webkit-calc(100% - 2px); + width: calc(100% - 2px); +}