diff --git a/Gemfile b/Gemfile index 5c3b0ff65..fdeb0ea2b 100644 --- a/Gemfile +++ b/Gemfile @@ -22,6 +22,7 @@ gem "thruster", require: false # Features gem "bcrypt", "~> 3.1.7" +gem "geared_pagination", "~> 1.2" gem "rqrcode" gem "redcarpet" gem "rouge" diff --git a/Gemfile.lock b/Gemfile.lock index 26b1c521b..cff4ceb78 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -157,6 +157,9 @@ GEM fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) + geared_pagination (1.2.0) + activesupport (>= 5.0) + addressable (>= 2.5.0) globalid (1.2.1) activesupport (>= 6.1) hotwire-spark (0.1.12) @@ -397,6 +400,7 @@ DEPENDENCIES brakeman capybara debug + geared_pagination (~> 1.2) hotwire-spark hotwire_combobox! importmap-rails diff --git a/app/assets/images/bell.svg b/app/assets/images/bell.svg new file mode 100644 index 000000000..52a5611fb --- /dev/null +++ b/app/assets/images/bell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/bubbles.css b/app/assets/stylesheets/bubbles.css index 864f0d34d..be3f39de4 100644 --- a/app/assets/stylesheets/bubbles.css +++ b/app/assets/stylesheets/bubbles.css @@ -402,8 +402,10 @@ --border-size: 2px; --divider-color: var(--color-subtle-dark); + block-size: calc(100% + 4ch); font-size: 1rem; cursor: grab; + margin-inline: -1rem; visibility: hidden; } diff --git a/app/assets/stylesheets/filters.css b/app/assets/stylesheets/filters.css index 745989b44..3d31519d7 100644 --- a/app/assets/stylesheets/filters.css +++ b/app/assets/stylesheets/filters.css @@ -86,4 +86,5 @@ inline-size: auto !important; max-inline-size: var(--panel-size) !important; + z-index: 1; } diff --git a/app/assets/stylesheets/layout.css b/app/assets/stylesheets/layout.css index 3cb50a695..b30ba7172 100644 --- a/app/assets/stylesheets/layout.css +++ b/app/assets/stylesheets/layout.css @@ -1,6 +1,6 @@ body { display: grid; - grid-template-rows: auto 1fr auto var(--block-space); + grid-template-rows: auto 1fr auto 10rem; } :where(#main) { diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css index 4b58106a9..ea8f772c6 100644 --- a/app/assets/stylesheets/notifications.css +++ b/app/assets/stylesheets/notifications.css @@ -1,84 +1,137 @@ .notification-tray { - height: 4rem; - inset: auto auto var(--block-space); + --size: 38ch; + + block-size: calc(4ch + var(--block-space-double)); + inline-size: var(--size); + inset: auto var(--inline-space) var(--block-space-half) auto; + padding: var(--block-space-half) var(--inline-space); position: fixed; + z-index: 1; + + .notification { + --outline-color: var(--color-link); + --offset: calc((var(--position) - 1) * (var(--block-space) * -1)); + --position: 1; + --scale: calc(1 - var(--position) / 30); + --z: calc(6 - var(--position)); + + inset: auto auto 0; + inline-size: var(--size); + pointer-events: none; + position: absolute; + scale: var(--scale); + transform: translateY(var(--offset)); + transform-origin: center; + transition: scale 0.2s ease-out, transform 0.2s ease-out; + z-index: var(--z); + + &:nth-child(1) { --position: 1; } + &:nth-child(2) { --position: 2; } + &:nth-child(3) { --position: 3; } + &:nth-child(4) { --position: 4; } + &:nth-child(5) { --position: 5; } + &:nth-child(6) { --position: 6; } + + &:nth-child(1n + 7) { display: none; } + + .notification-tray[open] & { + --scale: 1; + --offset: calc((100% + var(--block-space-half)) * (var(--position) * -1)); + + margin-block-end: calc(var(--block-space) * -1); + pointer-events: unset; + } + } } -.notification { - inset: auto auto 0; +.notification-tray__actions { + opacity: 0; + scale: calc(1 - 1 / 30); + transform: translateY(100%); + transition: opacity 0.2s ease-out, scale 0.2s ease-out, transform 0.2s ease-out; + + .notification-tray[open] & { + opacity: 1; + scale: 1; + transform: translateY(0); + } + + .notification-tray:has(.notification) & { + .btn img { + display: none; + } + } + + .notification-tray:not(:has(.notification)) & { + opacity: 1; + scale: 1; + transform: none; + transition: none; + + .notification-tray__all_action { + --btn-border-radius: 50%; + --btn-padding: 0; + + aspect-ratio: 1; + block-size: var(--btn-size); + display: grid; + inline-size: var(--btn-size); + place-items: center; + + > * { + grid-area: 1/1; + } + + span { + display: none; + } + } + + .notification-tray__read_action { + display: none; + } + } +} + +.notification__content { + color: var(--color-ink); + + .notificiations-list--read & { + border: 1px solid var(--color-subtle); + box-shadow: none; + } +} + +.notification-tray__expander { + --hover-size: 0; + --outline-size: 0; + + cursor: pointer; + inset: 0; position: absolute; - transform: translate(var(--offsetX), var(--offsetY)); - transform-origin: center center; - transition: transform 0.2s ease-in-out; - z-index: var(--z-index); + z-index: 6; - .notification__content { - color: var(--color-ink); - inline-size: var(--width); - } - - &:nth-child(1) { - --offsetX: 0; - --offsetY: 0; - --width: 40ch; - --z-index: 0; - } - - &:nth-child(2) { - --offsetX: 0.5ch; - --offsetY: calc(var(--block-space-half) * -1); - --width: 39ch; - --z-index: -1; - } - - &:nth-child(3) { - --offsetX: 1ch; - --offsetY: calc(var(--block-space) * -1); - --width: 38ch; - --z-index: -2; - } - - &:nth-child(4) { - --offsetX: 1.5ch; - --offsetY: calc(var(--block-space) * -1.5); - --width: 37ch; - --z-index: -3; - } - - &:nth-child(5) { - --offsetX: 2ch; - --offsetY: calc(var(--block-space) * -2); - --width: 36ch; - --z-index: -4; - } - - &:nth-child(1n + 6) { + .notification-tray[open] &, + .notification-tray:not(:has(.notification)) & { display: none; } +} - .notification-tray:hover & { - &:nth-child(2) { - --offsetX: 0; - --offsetY: calc((100% + var(--block-space-half)) * -1); - --width: 40ch; - } +.notification__unread_indicator { + --size: 0.8em; - &:nth-child(3) { - --offsetX: 0; - --offsetY: calc((100% + var(--block-space-half)) * -2); - --width: 40ch; - } + padding-block-start: var(--size); - &:nth-child(4) { - --offsetX: 0; - --offsetY: calc((100% + var(--block-space-half)) * -3); - --width: 40ch; - } + .dot { + aspect-ratio: 1; + background-color: var(--color-marker); + border-radius: 50%; + block-size: var(--size); + inline-size: var(--size); + margin-inline-end: var(--inline-space-half); + } - &:nth-child(5) { - --offsetX: 0; - --offsetY: calc((100% + var(--block-space-half)) * -4); - --width: 40ch; - } + .notificiations-list--read & { + display: none; } } diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index a512aca62..ff51ea9ff 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -32,6 +32,7 @@ .txt-reversed { color: var(--color-ink-reversed); } .txt-negative { color: var(--color-negative); } .txt-subtle { color: var(--color-subtle-dark); } +.txt-alert { color: var(--color-marker); } .txt-undecorated { text-decoration: none; } .txt-tight-lines { line-height: 1.2; } .txt-normal { font-weight: 400; font-style: normal; } diff --git a/app/controllers/notifications/mark_all_reads_controller.rb b/app/controllers/notifications/mark_all_reads_controller.rb new file mode 100644 index 000000000..2aad42028 --- /dev/null +++ b/app/controllers/notifications/mark_all_reads_controller.rb @@ -0,0 +1,5 @@ +class Notifications::MarkAllReadsController < ApplicationController + def create + Current.user.notifications.unread.read_all + end +end diff --git a/app/controllers/notifications/trays_controller.rb b/app/controllers/notifications/trays_controller.rb new file mode 100644 index 000000000..4a01a1ee9 --- /dev/null +++ b/app/controllers/notifications/trays_controller.rb @@ -0,0 +1,5 @@ +class Notifications::TraysController < ApplicationController + def show + @notifications = Current.user.notifications.unread.ordered.limit(20) + end +end diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index 22e40167e..83e5db488 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -1,5 +1,9 @@ class NotificationsController < ApplicationController def index - @notifications = Current.user.notifications.unread.ordered.limit(20) + set_page_and_extract_portion_from Current.user.notifications.read.ordered + + if @page.first? + @unread = Current.user.notifications.unread.ordered + end end end diff --git a/app/controllers/readings_controller.rb b/app/controllers/readings_controller.rb index 9c5798ed3..fb7112b76 100644 --- a/app/controllers/readings_controller.rb +++ b/app/controllers/readings_controller.rb @@ -8,6 +8,6 @@ class ReadingsController < ApplicationController private def mark_bubble_notifications_read - Current.user.notifications.where(bubble: @bubble).update(read: true) + Current.user.notifications.where(bubble: @bubble).read_all end end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index ddc142efb..5143ede77 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -4,6 +4,8 @@ module NotificationsHelper if notification.resource.is_a? Comment "RE: " + title + elsif notification_event_action(notification) == "assigned" + "Assigned to you: " + title else title end @@ -13,7 +15,6 @@ module NotificationsHelper name = notification.creator.name case notification_event_action(notification) - when "assigned" then "#{name} assigned to you" when "popped" then "Popped by by #{name}" when "published" then "Added by #{name}" else name @@ -22,7 +23,13 @@ module NotificationsHelper def notification_tag(notification, &) link_to notification.resource, id: dom_id(notification), class: "notification border-radius", - data: { turbo_frame: "_top" }, & + data: { action: "click->dialog#close", turbo_frame: "_top" }, & + end + + def notifications_next_page_link(page) + unless @page.last? + tag.div id: "next_page", data: { controller: "fetch-on-visible", fetch_on_visible_url_value: notifications_path(page: @page.next_param) } + end end private diff --git a/app/javascript/controllers/beacon_controller.js b/app/javascript/controllers/beacon_controller.js index 07182bb36..794565f78 100644 --- a/app/javascript/controllers/beacon_controller.js +++ b/app/javascript/controllers/beacon_controller.js @@ -7,4 +7,8 @@ export default class extends Controller { connect() { post(this.urlValue, { responseKind: "turbo-stream" }) } + + disconnect() { + post(this.urlValue, { responseKind: "turbo-stream" }) + } } diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js index dcea9bdf9..fd4d122e0 100644 --- a/app/javascript/controllers/dialog_controller.js +++ b/app/javascript/controllers/dialog_controller.js @@ -26,6 +26,7 @@ export default class extends Controller { close() { this.dialogTarget.close() + this.dialogTarget.blur() } closeOnClickOutside({ target }) { diff --git a/app/models/bubble/staged.rb b/app/models/bubble/staged.rb index 7f3997698..2b16734b9 100644 --- a/app/models/bubble/staged.rb +++ b/app/models/bubble/staged.rb @@ -3,6 +3,8 @@ module Bubble::Staged included do belongs_to :stage, class_name: "Workflow::Stage", optional: true + + scope :in_stage, ->(stage) { where stage: stage } end def workflow diff --git a/app/models/filter.rb b/app/models/filter.rb index 73b03c9c4..1fc09f68b 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -24,6 +24,7 @@ class Filter < ApplicationRecord result = result.assigned_to(assignees.ids) if assignees.present? result = result.assigned_by(assigners.ids) if assigners.present? result = result.in_bucket(buckets.ids) if buckets.present? + result = result.in_stage(stages.ids) if stages.present? result = result.tagged_with(tags.ids) if tags.present? result = terms.reduce(result) do |result, term| result.mentioning(term) diff --git a/app/models/filter/params.rb b/app/models/filter/params.rb index 91970d5b9..9d5fabfd3 100644 --- a/app/models/filter/params.rb +++ b/app/models/filter/params.rb @@ -1,7 +1,16 @@ module Filter::Params extend ActiveSupport::Concern - PERMITTED_PARAMS = [ :assignment_status, :indexed_by, assignee_ids: [], assigner_ids: [], bucket_ids: [], tag_ids: [], terms: [] ] + PERMITTED_PARAMS = [ + :assignment_status, + :indexed_by, + assignee_ids: [], + assigner_ids: [], + bucket_ids: [], + stage_ids: [], + tag_ids: [], + terms: [] + ] class_methods do def find_by_params(params) @@ -30,6 +39,7 @@ module Filter::Params params[:terms] = terms params[:tag_ids] = tags.ids params[:bucket_ids] = buckets.ids + params[:stage_ids] = stages.ids params[:assignee_ids] = assignees.ids params[:assigner_ids] = assigners.ids end.compact_blank.reject(&method(:default_value?)) diff --git a/app/models/filter/resources.rb b/app/models/filter/resources.rb index 537c52c35..dabeac4f7 100644 --- a/app/models/filter/resources.rb +++ b/app/models/filter/resources.rb @@ -4,6 +4,7 @@ module Filter::Resources included do has_and_belongs_to_many :tags has_and_belongs_to_many :buckets + has_and_belongs_to_many :stages, class_name: "Workflow::Stage", join_table: "filters_stages" has_and_belongs_to_many :assignees, class_name: "User", join_table: "assignees_filters", association_foreign_key: "assignee_id" has_and_belongs_to_many :assigners, class_name: "User", join_table: "assigners_filters", association_foreign_key: "assigner_id" end diff --git a/app/models/filter/summarized.rb b/app/models/filter/summarized.rb index fe65518d4..71f692c12 100644 --- a/app/models/filter/summarized.rb +++ b/app/models/filter/summarized.rb @@ -1,6 +1,6 @@ module Filter::Summarized def summary - [ index_summary, tag_summary, assignee_summary, assigner_summary, terms_summary ].compact.to_sentence + " #{bucket_summary}" + [ index_summary, tag_summary, assignee_summary, assigner_summary, stage_summary, terms_summary ].compact.to_sentence + " #{bucket_summary}" end private @@ -28,6 +28,12 @@ module Filter::Summarized end end + def stage_summary + if stages.any? + "staged in #{stages.pluck(:name).to_choice_sentence}" + end + end + def bucket_summary if buckets.any? "in #{buckets.pluck(:name).to_choice_sentence}" diff --git a/app/models/notification.rb b/app/models/notification.rb index 531be12bb..05d38c940 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -4,10 +4,21 @@ class Notification < ApplicationRecord belongs_to :bubble belongs_to :resource, polymorphic: true - scope :unread, -> { where(read: false) } - scope :ordered, -> { order(read: :desc, created_at: :desc) } + scope :unread, -> { where(read_at: nil) } + scope :read, -> { where.not(read_at: nil) } + scope :ordered, -> { order(read_at: :desc, created_at: :desc) } delegate :creator, to: :event broadcasts_to ->(notification) { [ notification.user, :notifications ] }, inserts_by: :prepend + + class << self + def read_all + update!(read_at: Time.current) + end + end + + def read? + read_at.present? + end end diff --git a/app/views/bubbles/_filters.html.erb b/app/views/bubbles/_filters.html.erb index 1538e22a2..eb1c64ed2 100644 --- a/app/views/bubbles/_filters.html.erb +++ b/app/views/bubbles/_filters.html.erb @@ -10,198 +10,6 @@ <% end %> -