diff --git a/Gemfile b/Gemfile index 63067f44b..18f89bdc3 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,5 @@ source "https://rubygems.org" +git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } ruby file: ".ruby-version" gem "rails", github: "rails/rails", branch: "main" @@ -19,6 +20,9 @@ gem "thruster", require: false # Features gem "bcrypt", "~> 3.1.7" gem "rqrcode" +gem "redcarpet" +gem "rouge" +gem "jbuilder" # Telemetry gem "sentry-ruby" diff --git a/Gemfile.lock b/Gemfile.lock index 73ede311a..0b8769eb0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -168,6 +168,9 @@ GEM irb (1.14.1) rdoc (>= 4.0.0) reline (>= 0.4.2) + jbuilder (2.13.0) + actionview (>= 5.0.0) + activesupport (>= 5.0.0) json (2.9.0) language_server-protocol (3.17.0.3) listen (3.9.0) @@ -249,10 +252,12 @@ GEM ffi (~> 1.0) rdoc (6.8.1) psych (>= 4.0.0) + redcarpet (3.6.0) regexp_parser (2.9.3) reline (0.5.12) io-console (~> 0.5) rexml (3.3.9) + rouge (4.5.1) rqrcode (2.2.0) chunky_png (~> 1.0) rqrcode_core (~> 1.0) @@ -363,9 +368,12 @@ DEPENDENCIES hotwire-spark hotwire_combobox! importmap-rails + jbuilder propshaft puma (>= 5.0) rails! + redcarpet + rouge rqrcode rubocop-rails-omakase selenium-webdriver diff --git a/app/assets/stylesheets/house.css b/app/assets/stylesheets/house.css new file mode 100644 index 000000000..4689f8105 --- /dev/null +++ b/app/assets/stylesheets/house.css @@ -0,0 +1,310 @@ +/* Colors */ +:root { + /* Named color values */ + --house-md-black: 0% 0 0; + --house-md-white: 100% 0 0; + --house-md-gray-light: 96% 0.005 96; + --house-md-gray: 92% 0.005 96; + --house-md-gray-dark: 75% 0.005 96; + --house-md-blue: 54% 0.23 255; + --house-md-blue-light: 95% 0.03 255; + --house-md-blue-dark: 80% 0.08 255; + --house-md-orange: 70% 0.2 44; + --house-md-red: 51% 0.2 31; + --house-md-green: 65.59% 0.234 142.49; + --house-md-always-black: 0% 0 0; + + /* Abstractions */ + --house-color-negative: oklch(var(--house-md-red)); + --house-color-positive: oklch(var(--house-md-green)); + --house-color-bg: oklch(var(--house-md-white)); + --house-color-ink: oklch(var(--house-md-black)); + --house-color-ink-reversed: oklch(var(--house-md-white)); + --house-color-link: oklch(var(--house-md-blue)); + --house-color-subtle-light: oklch(var(--house-md-gray-light)); + --house-color-subtle: oklch(var(--house-md-gray)); + --house-color-subtle-dark: oklch(var(--house-md-gray-dark)); + --house-color-selected: oklch(var(--house-md-blue-light)); + --house-color-selected-dark: oklch(var(--house-md-blue-dark)); + --house-color-alert: oklch(var(--house-md-orange)); + --house-color-always-black: oklch(var(--house-md-always-black)); + + /* Redefine named color values for dark mode */ + @media (prefers-color-scheme: dark) { + --house-md-black: 100% 0 0; + --house-md-white: 0% 0 0; + --house-md-gray: 25.2% 0 0; + --house-md-gray-dark: 30.12% 0 0; + --house-md-gray-darker: 44.95% 0 0; + --house-md-blue: 72.25% 0.16 248; + --house-md-blue-light: 28.11% 0.053 248; + --house-md-blue-dark: 42.25% 0.07 248; + --house-md-red: 73.8% 0.184 29.18; + --house-md-green: 75% 0.21 141.89; + } +} + +/* Base styles */ +:is(house-md-editor, house-md-toolbar) { +} + +a:not([class]) { + color: var(--house-color-link); + text-decoration-skip-ink: auto; +} + +:is(a, button, input, textarea) { + --outline-size: max(2px, 0.08em); + --outline-style: solid; + --house-outline-color: currentColor; + + caret-color: var(--house-color-link); + + &:focus { + outline: var(--outline-size) var(--outline-style) var(--house-outline-color); + outline-offset: var(--outline-offset, var(--outline-size)); + } + + &:focus-visible { + outline: var(--outline-size) var(--outline-style) var(--house-outline-color); + outline-offset: var(--outline-offset, var(--outline-size)); + } + + &:focus:not(:focus-visible) { + outline: none; + } + + ::selection { + background-color: var(--house-color-selected); + } +} + +/* Editor */ +house-md { + display: flex; + flex-direction: column; + flex-grow: 1; + + &:invalid { + border: var(--house-color-negative) 2px solid; + } +} + +/* Toolbar */ +house-md-toolbar { + background-color: inherit; + border: 1px solid currentColor; + border-radius: 0.5em; + color: currentColor; + display: inline-flex; + font-size: inherit; + padding: 0.2em; + + button, label { + aspect-ratio: 4/3; + appearance: none; + background-color: transparent; + block-size: 2em; + border: none; + border-radius: 0.5em; + color: currentColor; + cursor: pointer; + display: grid; + font-size: inherit; + inline-size: auto; + place-items: center; + transition: background-color 300ms ease; + + svg { + -webkit-touch-callout: none; + block-size: 0.9em; + fill: currentColor; + grid-area: 1/1; + inline-size: auto; + user-select: none; + } + + &:is(:focus, :hover) { + background-color: var(--house-color-subtle); + } + + &:is(:active) { + background-color: var(--house-color-selected); + } + } +} + +/* Markdown Content */ +.house-md-content { + caret-color: var(--color-link); + flex-grow: 1; + font-family: var(--font-sans); + min-block-size: 50dvh; + text-align: left; + white-space: break-spaces; + padding: 1rem; + + &.house-md-content-empty::before { + content: attr(placeholder); + color: var(--house-color-ink); + opacity: 0.5; + pointer-events: none; + display: block; + } + + &:active, &:focus{ + border:none; + outline:none; + } + + :is(.h1, .h2, .h3, .h4, .h5, .h6) { + display: inline-inblock; + font-weight: 600; + margin-block: 0; + } + + .h1 { + font-size: 2em; + } + + .h2 { + font-size: 1.5em; + } + + .h3 { + font-size: 1.17em; + } + + .h4 { + font-size: 1em; + } + + .h5 { + font-size: 0.83em; + } + + .h6 { + font-size: 0.67em; + } + + .link, .img { + color: var(--house-color-link); + text-decoration: underline; + } + + .hr { + border-color: var(--color-house-subtle-dark); + border-style: solid none none; + margin: 2lh auto; + } + + .code { + font-family: ui-monospace, monospace; + font-weight: 600; + font-size: 0.85em; + } + + .quote { + font-style: italic; + margin: 0 3ch; + } + + .quote, .ol-li, .ul-li { + padding-left: 1em; + } + + .comment { + color: var(--color-house-positive); + } + + mark { + background-color: rgba(241, 241, 26, 0.678); + } +} + +/* Uploads */ +house-md-upload { + border-radius: 0.5em; + margin-block: 0.5ex; + position: relative; + + &[status="failed"] { + background-color: var(--house-color-negative); + color: var(--house-color-ink-reversed); + font-weight: bold; + padding: 0.5em; + } +} + +.md-close { + appearance: none; + display: none; + inline-size: 1em; + block-size: 1em; + border: none; + outline: none; + background-color: transparent; + position: absolute; + inset: 0.5em 0.5em auto auto; + cursor: pointer; + + house-md-upload[status="failed"] & { + color: var(--house-color-ink-reversed); + display: inline-block; + } + + &::before { + content: '×'; + color: var(--house-color-ink-reversed); + font-size: 24px; + font-weight: bold; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } +} + +.md-file { + font-weight: normal; + + house-md-upload[status="complete"] & { + color: var(--house-color-positive); + } +} + +.md-progress-bar { + -webkit-appearance: none; + appearance: none; + background-color: var(--house-color-subtle); + border-radius: 0.5em; + block-size: 1ex; + display: block; + inline-size: 100%; + line-height: inherit; + margin: 0; + + house-md-upload[status="failed"] & , + house-md-upload[status="complete"] &{ + display: none; + } + + &::-webkit-progress-bar { + border-radius: 0.5em; + background-color: var(--house-color-subtle); + } + + &::-webkit-progress-inner-element { + border-radius: 0.5em; + } + + &::-webkit-progress-value { + border-radius: 0.5em; + background-color: var(--house-color-positive); + } + + &::-moz-progress-bar { + border-radius: 0.5em; + background-color: var(--house-color-positive); + } +} diff --git a/app/assets/stylesheets/lightbox.css b/app/assets/stylesheets/lightbox.css new file mode 100644 index 000000000..b2508149e --- /dev/null +++ b/app/assets/stylesheets/lightbox.css @@ -0,0 +1,57 @@ +.lightbox { + --backdrop-speed: 150ms; + + background-color: oklch(var(--lch-white) / 0.66); + block-size: 100dvh; + border: 0; + inline-size: 100dvw; + inset: 0; + margin: auto; + max-height: unset; + max-width: unset; + overflow: hidden; + padding: var(--block-space-half) var(--inline-space); + + &::backdrop { + -webkit-backdrop-filter: blur(66px); + backdrop-filter: blur(66px); + background-color: var(--color-ink); + opacity: 0; + transition: + display var(--backdrop-speed) allow-discrete, + opacity var(--backdrop-speed), + overlay var(--backdrop-speed) allow-discrete; + } + + &[open] { + display: grid; + opacity: 1; + place-items: center; + + &::backdrop { + opacity: 0.75; + } + } + + @starting-style { + &[open] { + opacity: 0; + } + + &[open]::backdrop { + opacity: 0; + } + } +} + +.lightbox__btn { + align-self: start; + grid-area: 1/1; + justify-self: end; +} + +.lightbox__image { + grid-area: 1/1; + max-inline-size: calc(100dvw - (var(--inline-space) * 2)); + max-block-size: calc(100dvh - (var(--block-space) * 2)); +} diff --git a/app/assets/stylesheets/markdown.css b/app/assets/stylesheets/markdown.css new file mode 100644 index 000000000..6ddc326c4 --- /dev/null +++ b/app/assets/stylesheets/markdown.css @@ -0,0 +1,30 @@ +.heading__link { + --hover-size: 0; + --opacity: 0.5; + --size: 0.8em; + + background: url(link.svg) no-repeat center bottom 0.2em; + background-size: var(--size); + block-size: 1em; + color: var(--color-link); + display: inline-flex; + font-size: var(--size); + inline-size: var(--size); + padding: 1em 0 0; + opacity: var(--opacity); + overflow: hidden; + transition: opacity 300ms ease; + vertical-align: middle; + + @media (hover: hover) { + --opacity: 0; + + :is(h1, h2, h3, h4, h5, h6):hover & { + --opacity: 0.5; + } + } + + @media (prefers-color-scheme: dark) { + filter: invert(1); + } +} diff --git a/app/assets/stylesheets/syntax.css b/app/assets/stylesheets/syntax.css new file mode 100644 index 000000000..148a02a6e --- /dev/null +++ b/app/assets/stylesheets/syntax.css @@ -0,0 +1,106 @@ +.highlight { + /* Named color values */ + --keyword: lch(50.16 68.78 25.97); + --entity: lch(39.03 73.26 304.21); + --constant: lch(39.68 63.13 279.47); + --string: lch(19.22 34.92 275.47); + --variable: lch(57.9 81.69 53.33); + --comment: lch(47.93 7 254.8); + --entity-tag: lch(39.64 68.17 142.85); + --markup-heading: lch(39.68 63.13 279.47); + --markup-list: lch(40.44 43.36 84.69); + --markup-inserted: lch(39.64 68.17 142.85); + --markup-deleted: lch(39.64 68.17 31.45); + + /* Redefine named color values for dark mode */ + @media (prefers-color-scheme: dark) { + --keyword: lch(67.63 58.99 30.64); + --entity: lch(75.13 46.73 306.74); + --constant: lch(74.9 39.71 255.53); + --string: lch(74.9 39.71 255.53); + --variable: lch(76.17 61.1 61.97); + --comment: lch(60.83 6.66 254.46); + --entity-tag: lch(83.65 59.31 141.61); + --markup-heading: lch(47.93 71.67 280.72); + --markup-list: lch(83.84 57.9 85.03); + --markup-inserted: lch(83.65 59.31 141.61); + --markup-deleted: lch(73.8% 65 29.18); + } + + color: var(--color-ink); + + .w { + color: var(--color-ink); + } + + .k, .kd, .kn, .kp, .kr, .kt, .kv { + color: var(--keyword); + } + + .gr { + color: var(--color-subtle-light); + } + + .gd { + color: var(--markup-deleted); + background-color: light-dark(lch(39.64 68.17 31.45 / 0.15), lch(39.64 68.17 31.45 / 0.2)); + } + + .nb, .nc, .no, .nn { + color: var(--variable); + } + + .sr, .na, .nt { + color: var(--entity-tag); + } + + .gi { + color: var(--markup-inserted); + background-color: light-dark(lch(49.14 52.75 142.85 / 0.15), lch(83.65 59.31 141.61 / 0.15)); + } + + .kc, .l, .ld, .m, .mb, .mf, .mh, .mi, .il, .mo, .mx, .sb, .bp, .ne, .nl, .py, .nv, .vc, .vg, .vi, .vm, .o, .ow { + color: var(--constant); + } + + .gh { + color: var(--constant); + font-weight: bold; + } + + .gu { + color: var(--constant); + font-weight: bold; + } + + .s, .sa, .sc, .dl, .sd, .s2, .se, .sh, .sx, .s1, .ss { + color: var(--string); + } + + .nd, .nf, .fm { + color: var(--entity); + } + + .err { + color: var(--color-ink-reversed); + background-color: var(--markup-deleted); + } + + .c, .ch, .cd, .cm, .cp, .cpf, .c1, .cs, .gl, .gt { + color: var(--comment); + } + + .ni, .si { + color: var(--storage-modifier-import); + } + + .ge { + color: var(--storage-modifier-import); + font-style: italic; + } + + .gs { + color: var(--storage-modifier-import); + font-weight: bold; + } +} diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb new file mode 100644 index 000000000..fda884cef --- /dev/null +++ b/app/controllers/uploads_controller.rb @@ -0,0 +1,28 @@ +class UploadsController < ApplicationController + include ActiveStorage::SetCurrent + + before_action :set_file, only: :create + before_action :set_attachment, only: :show + + def create + @upload = Current.account.uploads_attachments.create! blob: create_blob! + end + + def show + expires_in 1.year, public: true + redirect_to @attachment.url + end + + private + def set_file + @file = params[:file] + end + + def set_attachment + @attachment = ActiveStorage::Attachment.find_by! slug: "#{params[:slug]}.#{params[:format]}" + end + + def create_blob! + ActiveStorage::Blob.create_and_upload! io: @file, filename: @file.original_filename, content_type: @file.content_type + end +end diff --git a/app/javascript/application.js b/app/javascript/application.js index e917b49c5..c2917aa3e 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -2,3 +2,4 @@ import "@hotwired/turbo-rails" import "initializers" import "controllers" +import "house" diff --git a/app/javascript/controllers/lightbox_controller.js b/app/javascript/controllers/lightbox_controller.js new file mode 100644 index 000000000..c2a541a13 --- /dev/null +++ b/app/javascript/controllers/lightbox_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ "image", "dialog", "zoomedImage" ] + + open(event) { + this.dialogTarget.showModal() + this.#set(event.target.closest("a")) + } + + reset() { + this.zoomedImageTarget.src = "" + } + + #set(target) { + this.zoomedImageTarget.src = target.href + } +} diff --git a/app/models/account.rb b/app/models/account.rb index c7062e340..fb5815f4e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -10,4 +10,6 @@ class Account < ApplicationRecord has_many :stages, through: :workflows, class_name: "Workflow::Stage" has_many :tags, dependent: :destroy + + has_many_attached :uploads end diff --git a/app/models/comment.rb b/app/models/comment.rb index 4a9afb28b..3de28081f 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -3,5 +3,7 @@ class Comment < ApplicationRecord belongs_to :creator, class_name: "User", default: -> { Current.user } - searchable_by :body, using: :comments_search_index + searchable_by :body_plain_text, using: :comments_search_index, as: :body + + has_markdown :body end diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb index 125deb54e..b3bc64f5d 100644 --- a/app/models/concerns/searchable.rb +++ b/app/models/concerns/searchable.rb @@ -2,25 +2,26 @@ module Searchable extend ActiveSupport::Concern class_methods do - def searchable_by(field, using:) - define_method :search_field do field; end + def searchable_by(field, using:, as: field) + define_method :search_value do send(field); end + define_method :search_field do as; end define_method :search_table do using; end after_create_commit :create_in_search_index after_update_commit :update_in_search_index after_destroy_commit :remove_from_search_index - scope :search, ->(query) { joins("join #{using} idx on #{table_name}.id = idx.rowid").where("idx.#{field} match ?", query) } + scope :search, ->(query) { joins("join #{using} idx on #{table_name}.id = idx.rowid").where("idx.#{as} match ?", query) } end end private def create_in_search_index - execute_sql_with_binds "insert into #{search_table}(rowid, #{search_field}) values (?, ?)", id, send(search_field) + execute_sql_with_binds "insert into #{search_table}(rowid, #{search_field}) values (?, ?)", id, search_value end def update_in_search_index - execute_sql_with_binds "update #{search_table} set #{search_field} = ? where rowid = ?", send(search_field), id + execute_sql_with_binds "update #{search_table} set #{search_field} = ? where rowid = ?", search_value, id end def remove_from_search_index diff --git a/app/views/comments/_new.html.erb b/app/views/comments/_new.html.erb index b24034dd8..c95c3fdf3 100644 --- a/app/views/comments/_new.html.erb +++ b/app/views/comments/_new.html.erb @@ -2,7 +2,7 @@
<%= form_with model: Comment.new, url: bucket_bubble_comments_path(bubble.bucket, bubble), class: "flex flex-column gap full-width", data: { controller: "form", action: "keydown.meta+enter->form#submit" } do |form| %> - <%= form.text_area :body, class: "input comment__input", required: true, rows: 3, placeholder: new_comment_placeholder(bubble) %> + <%= form.markdown_area :body, class: "input comment__input", required: true, placeholder: new_comment_placeholder(bubble) %> <%= form.button class: "comment__submit btn btn--reversed flex-item-justify-end txt-small" do %> <%= image_tag "check.svg", aria: { hidden: "true" }, size: 24 %> Save diff --git a/app/views/comments/edit.html.erb b/app/views/comments/edit.html.erb index 23aa3d499..04d0b5a13 100644 --- a/app/views/comments/edit.html.erb +++ b/app/views/comments/edit.html.erb @@ -2,7 +2,7 @@
<%= form_with model: [@bubble.bucket, @bubble, @comment], class: "flex flex-column gap full-width", data: { controller: "form", action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel" } do |form| %> - <%= form.text_area :body, class: "input comment__input", required: true, rows: 3 %> + <%= form.markdown_area :body, class: "input comment__input", required: true %>
<%= form.button class: "btn btn--reversed txt-small", type: :submit do %> <%= image_tag "check.svg", aria: { hidden: true }, size: 16 %> diff --git a/app/views/comments/show.html.erb b/app/views/comments/show.html.erb index 94f7362af..eae7ba080 100644 --- a/app/views/comments/show.html.erb +++ b/app/views/comments/show.html.erb @@ -16,7 +16,7 @@ <% end %>
- <%= simple_format @comment.body %> + <%= sanitize @comment.body_html %>
<% end %> diff --git a/app/views/layouts/_lightbox.html.erb b/app/views/layouts/_lightbox.html.erb new file mode 100644 index 000000000..77c58be0c --- /dev/null +++ b/app/views/layouts/_lightbox.html.erb @@ -0,0 +1,10 @@ + + + + + diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index d977d6af0..2121cf22a 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -19,7 +19,7 @@ <%= yield :head %> - +