diff --git a/app/assets/stylesheets/base/root.css b/app/assets/stylesheets/base/root.css index 10d4984..7a9fd11 100644 --- a/app/assets/stylesheets/base/root.css +++ b/app/assets/stylesheets/base/root.css @@ -79,3 +79,71 @@ body.sidebar-collapsed .page-layout.with-sidebar > main.container { .page-layout.with-sidebar > main.container { grid-area: main; } + +.layout-sidebar-overlay { + display: none; +} + +@media (max-width: 900px) { + .container { + padding: 0; + } + + .page-layout.with-sidebar { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: "main"; + } + + body.sidebar-collapsed .page-layout.with-sidebar { + grid-template-columns: minmax(0, 1fr); + } + + .page-layout.with-sidebar .layout-sidebar { + position: fixed; + top: var(--nav-height); + left: 0; + height: calc(100vh - var(--nav-height)); + width: min(84vw, 360px); + max-width: 360px; + transform: translateX(-110%); + transition: transform var(--transition-normal); + z-index: 200; + box-shadow: 0 24px 48px rgba(15, 23, 42, 0.18); + } + + body.mobile-sidebar-open .page-layout.with-sidebar .layout-sidebar { + transform: translateX(0); + } + + body.sidebar-collapsed .page-layout.with-sidebar .layout-sidebar { + visibility: visible; + pointer-events: auto; + } + + .page-layout.with-sidebar .layout-sidebar-resizer { + display: none; + } + + .layout-sidebar-overlay { + display: block; + position: fixed; + top: var(--nav-height); + left: 0; + right: 0; + bottom: 0; + background: rgba(15, 23, 42, 0.45); + opacity: 0; + pointer-events: none; + transition: opacity var(--transition-normal); + z-index: 150; + } + + body.mobile-sidebar-open .layout-sidebar-overlay { + opacity: 1; + pointer-events: auto; + } + + body.mobile-sidebar-open { + overflow: hidden; + } +} diff --git a/app/assets/stylesheets/components/navigation.css b/app/assets/stylesheets/components/navigation.css index 16817e1..cd22ef8 100644 --- a/app/assets/stylesheets/components/navigation.css +++ b/app/assets/stylesheets/components/navigation.css @@ -19,12 +19,35 @@ align-items: center; min-height: var(--nav-height); gap: var(--spacing-4); + position: relative; +} + +.nav-burger { + display: none; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 999px; + border: var(--border-width) solid var(--color-border); + background: var(--color-bg-card); + cursor: pointer; + box-shadow: var(--shadow-sm); + transition: background-color var(--transition-fast), transform var(--transition-fast); + color: var(--color-text-secondary); + font-size: 1.1rem; +} + +.nav-burger:hover { + background: var(--color-bg-hover); + transform: scale(1.03); } .nav-brand { display: flex; align-items: center; gap: var(--spacing-3); + flex: 1 1 auto; } .brand-link { @@ -130,3 +153,125 @@ background: var(--color-bg-hover); } } + +.mobile-nav-dropdown { + display: none; + position: relative; + margin-left: var(--spacing-2); +} + +.mobile-nav-toggle { + list-style: none; + display: inline-flex; + align-items: center; + gap: var(--spacing-2); + cursor: pointer; + background: var(--color-bg-card); + border: var(--border-width) solid var(--color-border); + border-radius: 999px; + padding: var(--spacing-2) var(--spacing-4); + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); + box-shadow: var(--shadow-sm); + transition: background-color var(--transition-fast), transform var(--transition-fast); +} + +.mobile-nav-label { + display: inline; +} + +.mobile-nav-toggle::marker, +.mobile-nav-toggle::-webkit-details-marker { + display: none; +} + +.mobile-nav-toggle:hover { + background: var(--color-bg-hover); + transform: translateY(-1px); +} + +.mobile-nav-menu { + display: none; + position: absolute; + left: 0; + top: calc(100% + var(--spacing-3)); + background: var(--color-bg-card); + border: var(--border-width) solid var(--color-border); + border-radius: var(--border-radius-lg); + box-shadow: 0 24px 48px rgba(15, 23, 42, 0.18); + padding: var(--spacing-4); + gap: var(--spacing-3); + flex-direction: column; + z-index: 200; + width: min(92vw, 360px); + min-width: 240px; + max-width: calc(100vw - (var(--spacing-4) * 2)); + box-sizing: border-box; +} + +.mobile-nav-dropdown[open] .mobile-nav-menu { + display: flex; +} + +@media (max-width: 900px) { + .nav-container { + padding: 0 var(--spacing-4); + } + + .tagline { + display: none; + } + + .nav-links, + .nav-right { + display: none; + } + + body.has-sidebar .nav-burger { + display: inline-flex; + } + + .mobile-nav-dropdown { + display: inline-flex; + } + + .nav-brand { + gap: var(--spacing-2); + } + + .brand-link { + font-size: var(--font-size-lg); + } + + .mobile-nav-toggle { + padding: var(--spacing-1) var(--spacing-2); + border: none; + background: transparent; + box-shadow: none; + } + + .mobile-nav-label { + display: none; + } + + .mobile-nav-menu .nav-link { + width: 100%; + justify-content: space-between; + } + + .mobile-nav-menu .nav-user { + display: block; + padding: 0 var(--spacing-2); + } + + .mobile-nav-menu { + position: fixed; + left: var(--spacing-4); + right: var(--spacing-4); + top: calc(var(--nav-height) + var(--spacing-3)); + width: auto; + max-width: none; + max-height: calc(100vh - var(--nav-height) - var(--spacing-6)); + overflow-y: auto; + } +} diff --git a/app/assets/stylesheets/components/settings.css b/app/assets/stylesheets/components/settings.css index 996054f..5df30a3 100644 --- a/app/assets/stylesheets/components/settings.css +++ b/app/assets/stylesheets/components/settings.css @@ -247,6 +247,31 @@ padding: var(--spacing-2) var(--spacing-3); } +@media (max-width: 900px) { + .settings-page .email-table { + table-layout: fixed; + } + + .settings-page .email-table th, + .settings-page .email-table td { + padding: var(--spacing-2); + font-size: var(--font-size-xs); + word-break: break-word; + white-space: normal; + } + + .settings-page .email-table td:last-child { + flex-wrap: wrap; + gap: var(--spacing-2); + } + + .settings-page .email-table .button-secondary, + .settings-page .email-table .button-danger { + width: 100%; + justify-content: center; + } +} + .settings-page .team-list, .settings-page .member-list { list-style: none; diff --git a/app/assets/stylesheets/components/sidebar.css b/app/assets/stylesheets/components/sidebar.css index 23db824..e418190 100644 --- a/app/assets/stylesheets/components/sidebar.css +++ b/app/assets/stylesheets/components/sidebar.css @@ -45,6 +45,16 @@ } } +@media (max-width: 900px) { + .sidebar { + position: static; + top: auto; + max-height: none; + height: 100%; + padding-right: 0; + } +} + .sidebar-section { margin-bottom: var(--spacing-8); } diff --git a/app/assets/stylesheets/components/topics.css b/app/assets/stylesheets/components/topics.css index 8629d4e..0d61a4b 100644 --- a/app/assets/stylesheets/components/topics.css +++ b/app/assets/stylesheets/components/topics.css @@ -202,6 +202,59 @@ a.topic-icon { } } +@media (max-width: 900px) { + .topics-table { + & th, + & td { + padding: var(--spacing-2); + } + + & th.participants-header, + & td.topic-participants { + display: none; + } + + & th.activity-header, + & td.topic-activity { + display: none; + } + } + + .topic-title-mobile { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + } + + .topic-icons { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-2); + } + + .topic-icon { + padding: 2px; + margin-right: 0; + } + + .topic-icon-badge { + min-width: 16px; + height: 16px; + font-size: 10px; + } + + .topic-link { + font-size: var(--font-size-base); + line-height: var(--line-height-normal); + } + + .topic-byline { + font-size: var(--font-size-2xs); + } + +} + + .activity-star { background-color: var(--color-bg-activity-team); .topic-icon-badge { @@ -321,6 +374,110 @@ a.topic-icon { } } + +.topic-title-main { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); +} + +.topic-title-mobile { + display: none; + flex-direction: column; + gap: var(--spacing-2); +} + +.topic-row-footer { + display: flex; + align-items: center; + gap: var(--spacing-3); + width: 100%; +} + +.topic-footer-icons { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); +} + +.topic-footer-replies { + text-align: center; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + display: none; +} + +.topic-footer-time { + text-align: right; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + white-space: nowrap; + display: none; +} + + +.topic-icons { + display: inline-flex; + flex-wrap: wrap; + gap: var(--spacing-2); + align-items: center; +} + +.topic-byline { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-2); + justify-content: space-between; + color: var(--color-text-muted); + font-size: var(--font-size-sm); +} + +.topic-byline-item { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); +} + +.topic-byline-right { + margin-left: auto; +} + +.topic-byline-avatar { + width: 20px; + height: 20px; + border-radius: 999px; + flex-shrink: 0; +} + +@media (max-width: 900px) { + .topic-title-main { + display: none; + } + + .topic-title-mobile { + display: flex; + } + + .topic-row-footer { + display: grid; + grid-template-columns: var(--topic-footer-icons-width, 96px) minmax(0, 1fr) auto; + align-items: center; + gap: var(--spacing-3); + } + + .topic-footer-icons { + width: var(--topic-footer-icons-width, 96px); + justify-content: flex-start; + } + + .topic-footer-replies, + .topic-footer-time { + display: block; + font-size: var(--font-size-2xs); + } + +} + .topic-link { color: var(--color-text-link); text-decoration: none; diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index bbd887c..e480382 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -5,7 +5,7 @@ class TopicsController < ApplicationController def index @search_query = nil - base_query = apply_filters(Topic.includes(:creator)) + base_query = apply_filters(Topic.includes(:creator, creator_person: :default_alias, last_sender_person: :default_alias)) apply_cursor_pagination(base_query) preload_topic_participants @@ -240,7 +240,7 @@ def user_state_frame return head :unauthorized unless user_signed_in? return head :ok if topic_ids.empty? - @topics = Topic.includes(:creator).where(id: topic_ids) + @topics = Topic.includes(:creator, creator_person: :default_alias, last_sender_person: :default_alias).where(id: topic_ids) preload_topic_states preload_note_counts preload_participation_flags @@ -771,7 +771,7 @@ def filter_team_readers(topic, team_id:) end def topics_base_query(search_query: nil) - return apply_filters(Topic.includes(:creator)) if search_query.nil? + return apply_filters(Topic.includes(:creator, creator_person: :default_alias, last_sender_person: :default_alias)) if search_query.nil? cleaned_query = search_query.to_s.strip return Topic.none if cleaned_query.blank? @@ -790,7 +790,6 @@ def build_search_query(query) union_sql = "(#{title_sql}) UNION (#{message_sql})" Topic.where("topics.id IN (#{union_sql})") - .includes(:creator) end def viewing_since_param @@ -930,7 +929,7 @@ def hydrate_topics_from_entries(entries) ids = entries.map { |e| e[:id] } return [] if ids.empty? - topics_map = Topic.includes(:creator).where(id: ids).index_by(&:id) + topics_map = Topic.includes(:creator, creator_person: :default_alias, last_sender_person: :default_alias).where(id: ids).index_by(&:id) entries.filter_map do |entry| topic = topics_map[entry[:id]] next unless topic diff --git a/app/javascript/controllers/sidebar_controller.js b/app/javascript/controllers/sidebar_controller.js index a02a3ed..7327d23 100644 --- a/app/javascript/controllers/sidebar_controller.js +++ b/app/javascript/controllers/sidebar_controller.js @@ -8,21 +8,24 @@ const MAX_WIDTH = 960 const MAX_WIDTH_RATIO = 0.75 export default class extends Controller { - static targets = ["layout", "sidebar", "resizer", "toggleButton", "toggleIcon"] + static targets = ["layout", "sidebar", "resizer", "toggleButton", "toggleIcon", "overlay"] connect() { + this.handleWindowResize = this.handleWindowResize.bind(this) + this.handleDocumentClick = this.handleDocumentClick.bind(this) + window.addEventListener("resize", this.handleWindowResize) + document.addEventListener("click", this.handleDocumentClick) + if (!this.hasLayoutTarget || !this.hasSidebarTarget) { return } - this.handleWindowResize = this.handleWindowResize.bind(this) - window.addEventListener("resize", this.handleWindowResize) - this.applyStoredState() } disconnect() { window.removeEventListener("resize", this.handleWindowResize) + document.removeEventListener("click", this.handleDocumentClick) this.stopResize() } @@ -83,6 +86,15 @@ export default class extends Controller { } handleWindowResize() { + if (this.isMobile()) { + this.closeMobile() + return + } + + if (!this.hasLayoutTarget || !this.hasSidebarTarget) { + return + } + if (this.isCollapsed()) { return } @@ -119,6 +131,74 @@ export default class extends Controller { this.updateToggleIcon() } + toggleMobile() { + if (!this.isMobile()) { + this.toggle() + return + } + + if (this.isMobileOpen()) { + this.closeMobile() + } else { + this.openMobile() + } + } + + openMobile() { + document.body.classList.add("mobile-sidebar-open") + } + + closeMobile() { + document.body.classList.remove("mobile-sidebar-open") + } + + isMobileOpen() { + return document.body.classList.contains("mobile-sidebar-open") + } + + isMobile() { + return window.matchMedia("(max-width: 900px)").matches + } + + closeOnNavigate(event) { + if (!this.isMobile()) { + return + } + + const link = event.target.closest("a") + if (!link) { + return + } + + this.closeMobile() + } + + closeMenuOnNavigate() { + const menu = document.querySelector(".mobile-nav-dropdown[open]") + if (!menu) { + return + } + + menu.removeAttribute("open") + } + + handleDocumentClick(event) { + if (!this.isMobile()) { + return + } + + const menu = document.querySelector(".mobile-nav-dropdown[open]") + if (!menu) { + return + } + + if (menu.contains(event.target)) { + return + } + + menu.removeAttribute("open") + } + updateToggleIcon() { if (!this.hasToggleIconTarget) { return diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim index 64325a7..2aa2233 100644 --- a/app/views/layouts/application.html.slim +++ b/app/views/layouts/application.html.slim @@ -29,16 +29,45 @@ html data-theme="light" - if ENV["UMAMI_WEBSITE_ID"].present? - umami_host = ENV["UMAMI_HOST"].presence || "https://umami.hackorum.dev" script[async defer data-website-id=ENV["UMAMI_WEBSITE_ID"] src="#{umami_host}/script.js"] - body + body class=(content_for?(:sidebar) ? "has-sidebar" : nil) data-controller="sidebar" - if user_signed_in? && current_user.username.blank? .global-warning span Please set a username in Settings. nav.main-navigation .nav-container .nav-brand + - if content_for?(:sidebar) + button.nav-burger type="button" aria-label="Toggle sidebar" data-action="click->sidebar#toggleMobile" + i.fa-solid.fa-bars = link_to root_path, class: "brand-link" do img.brand-icon src="/icon.png" alt="Hackorum" width="24" height="24" span.brand-text Hackorum + details.mobile-nav-dropdown + summary.mobile-nav-toggle aria-label="Open menu" + span.mobile-nav-label Menu + i.fa-solid.fa-caret-down + .mobile-nav-menu data-action="click->sidebar#closeMenuOnNavigate" + = link_to "Topics", topics_path, class: "nav-link" + = link_to "Search", topics_path(anchor: "search"), class: "nav-link" + = link_to "Statistics", stats_path, class: "nav-link" + = link_to "Reports", reports_path, class: "nav-link" + = link_to "Help", help_index_path, class: "nav-link" + button.nav-link.theme-toggle type="button" aria-label="Toggle theme" data-controller="theme" data-action="click->theme#toggle" + i.fas.fa-moon data-theme-target="icon" + span data-theme-target="label" Theme + - if user_signed_in? + - if current_user&.person&.default_alias + span.nav-user = current_user.person.default_alias.name + - unread = activity_unread_count + = link_to activities_path, class: "nav-link nav-link-activity", title: "Activity" do + i.fa-regular.fa-bell + - if unread.positive? + span.nav-badge = unread + = link_to "Settings", settings_root_path, class: "nav-link" + = button_to "Sign out", session_path, method: :delete, class: "nav-link", form: { style: 'display:inline' }, data: { turbo: false } + - else + = link_to "Sign in", new_session_path, class: "nav-link" + = link_to "Register", new_registration_path, class: "nav-link" span.tagline PostgreSQL Hackers Archive .nav-links = link_to "Topics", topics_path, class: "nav-link" @@ -67,12 +96,13 @@ html data-theme="light" = link_to "Register", new_registration_path, class: "nav-link" - if content_for?(:sidebar) - .page-layout.with-sidebar data-controller="sidebar" data-sidebar-target="layout" - .layout-sidebar#layout-sidebar data-sidebar-target="sidebar" + .page-layout.with-sidebar data-sidebar-target="layout" + .layout-sidebar#layout-sidebar data-sidebar-target="sidebar" data-action="click->sidebar#closeOnNavigate" = yield :sidebar .layout-sidebar-resizer data-sidebar-target="resizer" role="separator" aria-orientation="vertical" aria-label="Resize sidebar" data-action="mousedown->sidebar#startResize touchstart->sidebar#startResize" button.sidebar-collapse-button type="button" aria-label="Toggle sidebar" data-action="click->sidebar#toggle" data-sidebar-target="toggleButton" span data-sidebar-target="toggleIcon" ◀ + .layout-sidebar-overlay data-sidebar-target="overlay" data-action="click->sidebar#closeMobile" main.container - flash.each do |type, message| .flash class=type diff --git a/app/views/topics/_status_cell.html.slim b/app/views/topics/_status_cell.html.slim index 72d9fc0..27d28de 100644 --- a/app/views/topics/_status_cell.html.slim +++ b/app/views/topics/_status_cell.html.slim @@ -2,24 +2,65 @@ - status_class = "status-#{status}" - star_data = star_data || {} td.topic-title.status-border class=status_class id=dom_id(topic, "status_cell") data-label="Topic" - - if status.to_s == "reading" - - read_count = state[:read_count].to_i - - total_count = topic.message_count - - unread_count = [total_count - read_count, 0].max - - if unread_count.positive? - = link_to topic_path(topic, anchor: "first-unread"), class: "topic-icon topic-icon-reading", title: "Jump to first unread message (#{unread_count} unread)" do - i.fa-solid.fa-envelope - span.topic-icon-badge.topic-icon-badge-sup = unread_count - - else - .topic-icon.topic-icon-reading title="All messages read" - i.fa-solid.fa-envelope - = render partial: "topics/star_icon", locals: { topic: topic, star_data: star_data } - = render partial: "topics/note_icon", locals: { topic: topic, count: note_count.to_i } - = render partial: "topics/team_readers_icon", locals: { topic: topic, readers: team_readers } - - commitfest_summary = @commitfest_summaries&.dig(topic.id) - - if commitfest_summary - = render partial: "topics/commitfest_icon", locals: { summary: commitfest_summary } - - elsif topic.has_attachments? - .topic-icon title="Attachments" - i.fa-solid.fa-paperclip - = link_to topic.title, topic_path(topic), class: "topic-link" + - creator = topic.creator_display_alias + - last_sender = topic.last_sender_person&.default_alias + .topic-title-main + - if status.to_s == "reading" + - read_count = state[:read_count].to_i + - total_count = topic.message_count + - unread_count = [total_count - read_count, 0].max + - if unread_count.positive? + = link_to topic_path(topic, anchor: "first-unread"), class: "topic-icon topic-icon-reading", title: "Jump to first unread message (#{unread_count} unread)" do + i.fa-solid.fa-envelope + span.topic-icon-badge.topic-icon-badge-sup = unread_count + - else + .topic-icon.topic-icon-reading title="All messages read" + i.fa-solid.fa-envelope + = render partial: "topics/star_icon", locals: { topic: topic, star_data: star_data } + = render partial: "topics/note_icon", locals: { topic: topic, count: note_count.to_i } + = render partial: "topics/team_readers_icon", locals: { topic: topic, readers: team_readers } + - commitfest_summary = @commitfest_summaries&.dig(topic.id) + - if commitfest_summary + = render partial: "topics/commitfest_icon", locals: { summary: commitfest_summary } + - elsif topic.has_attachments? + .topic-icon title="Attachments" + i.fa-solid.fa-paperclip + = link_to topic.title, topic_path(topic), class: "topic-link" + .topic-title-mobile + = link_to topic.title, topic_path(topic), class: "topic-link" + - if creator || last_sender + .topic-byline + - if creator + span.topic-byline-item + = image_tag creator.gravatar_url(size: 20), class: "topic-byline-avatar", alt: creator.name + span.topic-byline-name = creator.name + - if last_sender + span.topic-byline-item.topic-byline-right + = image_tag last_sender.gravatar_url(size: 20), class: "topic-byline-avatar", alt: last_sender.name + span.topic-byline-name = last_sender.name + .topic-row-footer + .topic-footer-icons + .topic-icons + - if status.to_s == "reading" + - read_count = state[:read_count].to_i + - total_count = topic.message_count + - unread_count = [total_count - read_count, 0].max + - if unread_count.positive? + = link_to topic_path(topic, anchor: "first-unread"), class: "topic-icon topic-icon-reading", title: "Jump to first unread message (#{unread_count} unread)" do + i.fa-solid.fa-envelope + span.topic-icon-badge.topic-icon-badge-sup = unread_count + - else + .topic-icon.topic-icon-reading title="All messages read" + i.fa-solid.fa-envelope + = render partial: "topics/star_icon", locals: { topic: topic, star_data: star_data } + = render partial: "topics/note_icon", locals: { topic: topic, count: note_count.to_i } + = render partial: "topics/team_readers_icon", locals: { topic: topic, readers: team_readers } + - commitfest_summary = @commitfest_summaries&.dig(topic.id) + - if commitfest_summary + = render partial: "topics/commitfest_icon", locals: { summary: commitfest_summary } + - elsif topic.has_attachments? + .topic-icon title="Attachments" + i.fa-solid.fa-paperclip + - replies_count = [topic.message_count - 1, 0].max + .topic-footer-replies = pluralize(replies_count, "reply") + .topic-footer-time title=absolute_time_display(topic.last_message_at) = smart_time_display(topic.last_message_at) diff --git a/app/views/topics/_topics_page.html.slim b/app/views/topics/_topics_page.html.slim index 86b1e5b..bc3ba56 100644 --- a/app/views/topics/_topics_page.html.slim +++ b/app/views/topics/_topics_page.html.slim @@ -33,7 +33,7 @@ - role_label = participant.contributor_badge - badge_text = role_label ? "#{participant.name} (#{role_label})" : participant.name = link_to person_path(participant.email), class: "participant-avatar-link" do - = image_tag participant.display_gravatar_url(size: 32), class: css_classes.join(" "), alt: participant.name, title: badge_text + = image_tag participant.gravatar_url(size: 32), class: css_classes.join(" "), alt: participant.name, title: badge_text - if topic.participant_count > 5 span.participants-count +#{topic.participant_count - 5} .topic-column.replies-col