Merge pull request #144 from basecamp/house

Use House for MD comments
This commit is contained in:
Jose Farias
2024-12-18 17:40:36 -06:00
committed by GitHub
42 changed files with 897 additions and 26 deletions
+4
View File
@@ -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"
+8
View File
@@ -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
+310
View File
@@ -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);
}
}
+57
View File
@@ -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));
}
+30
View File
@@ -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);
}
}
+106
View File
@@ -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;
}
}
+28
View File
@@ -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
+1
View File
@@ -2,3 +2,4 @@
import "@hotwired/turbo-rails"
import "initializers"
import "controllers"
import "house"
@@ -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
}
}
+2
View File
@@ -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
+3 -1
View File
@@ -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
+6 -5
View File
@@ -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
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="flex flex-column full-width">
<div class="position-relative">
<%= 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 %>
<span class="for-screen-reader">Save</span>
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="comment__content flex-inline flex-column full-width border border-radius" style="background-color: var(--color-selected);">
<%= 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 %>
<div class="flex gap-half justify-start">
<%= form.button class: "btn btn--reversed txt-small", type: :submit do %>
<%= image_tag "check.svg", aria: { hidden: true }, size: 16 %>
+1 -1
View File
@@ -16,7 +16,7 @@
<% end %>
</div>
<div class="comment__body txt-align-start">
<%= simple_format @comment.body %>
<%= sanitize @comment.body_html %>
</div>
</div>
<% end %>
+10
View File
@@ -0,0 +1,10 @@
<dialog class="lightbox" aria-label="Image Viewer (Press escape to close)" data-lightbox-target="dialog" data-action="close->lightbox#reset">
<img src="" class="lightbox__image" data-lightbox-target="zoomedImage" />
<form method="dialog" class="lightbox__btn">
<button class="btn fill-white" title="Close (esc)">
<%= image_tag "remove.svg", aria: { hidden: "true" } %>
<span class="for-screen-reader">Close image viewer (esc)</span>
</button>
</form>
</dialog>
+3 -1
View File
@@ -19,7 +19,7 @@
<%= yield :head %>
</head>
<body>
<body data-controller="lightbox">
<header id="header">
<a href="#main-content" class="skip-navigation btn">Skip to main content</a>
<%= yield :header %>
@@ -45,5 +45,7 @@
<footer id="footer">
<%= yield :footer %>
</footer>
<%= render "layouts/lightbox" %>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
json.message "File uploaded successfully"
json.fileName @upload.filename.to_s
json.mimetype @upload.content_type
json.fileUrl @upload.slug_url
+1 -1
View File
@@ -14,7 +14,7 @@ module Fizzy
# Include the `lib` directory in autoload paths. Use the `ignore:` option
# to list subdirectories that don't contain `.rb` files or that shouldn't
# be reloaded or eager loaded.
config.autoload_lib ignore: %w[ assets tasks ]
config.autoload_lib ignore: %w[ assets tasks rails_ext ]
# Configuration for the application, engines, and railties goes here.
#
+1
View File
@@ -4,6 +4,7 @@ pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "house", to: "house.min.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin_all_from "app/javascript/helpers", under: "helpers"
+1 -3
View File
@@ -1,3 +1 @@
%w[ rails_ext ].each do |extensions_dir|
Dir["#{Rails.root}/lib/#{extensions_dir}/*"].each { |path| require "#{extensions_dir}/#{File.basename(path)}" }
end
Dir["#{Rails.root}/lib/rails_ext/*"].each { |path| require "rails_ext/#{File.basename(path)}" }
+7
View File
@@ -0,0 +1,7 @@
ActiveSupport.on_load :action_text_markdown do
require "markdown_renderer"
require "redcarpet/render_strip"
ActionText::Markdown.html_renderer = ->(content) { MarkdownRenderer.build.render(content) }
ActionText::Markdown.plain_text_renderer = Redcarpet::Markdown.new(Redcarpet::Render::StripDown)
end
+3
View File
@@ -0,0 +1,3 @@
Rails.application.config.after_initialize do
Rails::HTML5::SafeListSanitizer.allowed_tags.merge(%w[ s table tr td th thead tbody details summary ])
end
+5
View File
@@ -0,0 +1,5 @@
Rails.application.config.after_initialize do
%w[ db files ].each do |dir|
Rails.root.join("storage", dir).mkpath
end
end
+3
View File
@@ -40,6 +40,9 @@ Rails.application.routes.draw do
resource :first_run
resource :session
resources :uploads, only: :create
get "/u/*slug" => "uploads#show", as: :upload
resources :users do
scope module: :users do
resource :avatar
+1 -1
View File
@@ -4,7 +4,7 @@ test:
local:
service: Disk
root: <%= Rails.root.join("storage") %>
root: <%= Rails.root.join("storage", "files") %>
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
@@ -0,0 +1,13 @@
class CreateActionTextMarkdowns < ActiveRecord::Migration[8.0]
def change
create_table :action_text_markdowns do |t|
t.references :record, polymorphic: true, null: false
t.string :name, null: false
t.text :content, null: false, default: ""
t.timestamps
end
add_column :active_storage_attachments, :slug, :string
add_index :active_storage_attachments, :slug, unique: true
end
end
@@ -0,0 +1,5 @@
class RemoveBodyFromComments < ActiveRecord::Migration[8.0]
def change
remove_column :comments, :body, :text, null: false
end
end
Generated
+12 -1
View File
@@ -30,14 +30,26 @@ ActiveRecord::Schema[8.1].define(version: 2024_12_09_223551) do
t.index ["name"], name: "index_accounts_on_name", unique: true
end
create_table "action_text_markdowns", force: :cascade do |t|
t.string "record_type", null: false
t.integer "record_id", null: false
t.string "name", null: false
t.text "content", default: "", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["record_type", "record_id"], name: "index_action_text_markdowns_on_record"
end
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
t.bigint "record_id", null: false
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.string "slug"
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
t.index ["slug"], name: "index_active_storage_attachments_on_slug", unique: true
end
create_table "active_storage_blobs", force: :cascade do |t|
@@ -117,7 +129,6 @@ ActiveRecord::Schema[8.1].define(version: 2024_12_09_223551) do
end
create_table "comments", force: :cascade do |t|
t.text "body", null: false
t.integer "creator_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+48
View File
@@ -0,0 +1,48 @@
require "rouge/plugins/redcarpet"
class MarkdownRenderer < Redcarpet::Render::HTML
include Rouge::Plugins::Redcarpet
def self.build
Redcarpet::Markdown.new MarkdownRenderer.new(filter_html: false),
autolink: true, highlight: true, no_intra_emphasis: true,
fenced_code_blocks: true, lax_spacing: true, strikethrough: true, tables: true
end
def initialize(...)
super
@id_counts = Hash.new 0
end
def header(text, header_level)
unique_id(text).then do |id|
<<~HTML.chomp
<h#{header_level} id="#{id}">
#{text} <a href="##{id}" class="heading__link" aria-hidden="true">#</a>
</h#{header_level}>
HTML
end
end
def image(url, title, alt_text)
<<~HTML.chomp
<a title="#{title}" href="#{url}" data-action="lightbox#open:prevent" data-lightbox-target="image" data-lightbox-url-value="#{url}?disposition=attachment">
<img src="#{url}" alt="#{alt_text}">
</a>
HTML
end
def code_block(code, language)
block_code(code, language) # call Rouge Redcarpet plugin
end
private
attr_reader :id_counts
def unique_id(text)
text.parameterize.then do |base_id|
id_counts[base_id] += 1
id_counts[base_id] > 1 ? "#{base_id}-#{id_counts[base_id]}" : base_id
end
end
end
+47
View File
@@ -0,0 +1,47 @@
module ActionText
module HasMarkdown
extend ActiveSupport::Concern
class_methods do
def has_markdown(name, strict_loading: strict_loading_by_default)
class_eval <<-CODE, __FILE__, __LINE__ + 1
def #{name}
markdown_#{name} || build_markdown_#{name}
end
def #{name}_html
#{name}.to_html
end
def #{name}_plain_text
#{name}.to_plain_text
end
def #{name}?
markdown_#{name}.present?
end
def #{name}=(content)
self.#{name}.content = content
end
CODE
has_one :"markdown_#{name}", -> { where(name: name) },
class_name: "ActionText::Markdown", as: :record, inverse_of: :record, autosave: true, dependent: :destroy,
strict_loading: strict_loading
scope :"with_markdown_#{name}", -> { includes("markdown_#{name}") }
end
end
def safe_markdown_attribute(name)
if self.class.reflect_on_association("markdown_#{name}")&.klass == ActionText::Markdown
public_send(name)
end
end
end
end
ActiveSupport.on_load :active_record do
include ActionText::HasMarkdown
end
+44
View File
@@ -0,0 +1,44 @@
module ActionText
class Markdown < Record
mattr_accessor :html_renderer
mattr_accessor :plain_text_renderer
belongs_to :record, polymorphic: true, touch: true
def to_html
to_unsafe_html.html_safe
end
def to_unsafe_html
if html_renderer.respond_to? :call
html_renderer.call content
else
html_renderer.render content
end
end
def to_plain_text
if plain_text_renderer.respond_to? :call
plain_text_renderer.call content
else
plain_text_renderer.render content
end
end
end
end
module ActionText::Markdown::Uploads
extend ActiveSupport::Concern
included do
has_many_attached :uploads, dependent: :destroy
end
end
ActiveSupport.on_load :active_storage_attachment do
class ActionText::Markdown
include ActionText::Markdown::Uploads
end
end
ActiveSupport.run_load_hooks :action_text_markdown, ActionText::Markdown
+36
View File
@@ -0,0 +1,36 @@
module ActionText
module TagHelper
def markdown_area(record, name, value: nil, data: {}, **options)
field_name = "#{record.class.model_name.param_key}[#{name}]"
value = record.safe_markdown_attribute(name).content.to_s if value.nil?
data = data.reverse_merge! uploads_url: uploads_url(format: "json")
tag.house_md value, name: field_name, data: data, **options
end
def house_toolbar(**options, &block)
tag.house_md_toolbar(**options, &block)
end
def house_toolbar_button(action, **options, &block)
tag.button title: action.to_s.humanize, data: { "house-md-action": action }, **options, &block
end
def house_toolbar_file_upload_button(name: "upload", title: "Upload File", **options, &block)
tag.label title: title, **options do
safe_join [
file_field_tag(name, data: { "house-md-toolbar-file-picker": true }, style: "display: none;"),
capture(&block)
]
end
end
end
end
module ActionView::Helpers
class FormBuilder
def markdown_area(method, **options)
@template.markdown_area(@object, method, **options)
end
end
end
+32
View File
@@ -0,0 +1,32 @@
module ActiveStorage::Sluggable
extend ActiveSupport::Concern
included do
before_create :set_slug
end
def slug_url(host: ActiveStorage::Current.url_options[:host], port: ActiveStorage::Current.url_options[:port])
Rails.application.routes.url_helpers.upload_url(slug, host: host, port: port)
end
private
def set_slug
self.slug = "#{slug_basename}-#{SecureRandom.alphanumeric(6)}.#{slug_extension}"
end
def slug_basename
File.basename(slug_filename, ".*").parameterize
end
def slug_extension
File.extname(slug_filename).delete(".").parameterize
end
def slug_filename
slug.presence || filename.to_s
end
end
ActiveSupport.on_load :active_storage_attachment do
ActiveStorage::Attachment.include ActiveStorage::Sluggable
end
@@ -1,9 +1,7 @@
module RailsExt
module ActiveSupportArrayConversions
def to_choice_sentence
to_sentence two_words_connector: " or ", last_word_connector: ", or "
end
module ChoiceSentenceArrayConversion
def to_choice_sentence
to_sentence two_words_connector: " or ", last_word_connector: ", or "
end
end
Array.include RailsExt::ActiveSupportArrayConversions
Array.include ChoiceSentenceArrayConversion
@@ -0,0 +1,25 @@
require "test_helper"
class UploadsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "create" do
assert_changes -> { ActiveStorage::Attachment.count }, 1 do
post uploads_url(format: "json"), params: { file: fixture_file_upload("moon.jpg", "image/jpeg") }, as: :xhr
end
assert_response :success
assert_equal ActiveStorage::Attachment.last.slug_url(host: "www.example.com", port: nil), response.parsed_body["fileUrl"]
assert_equal "image/jpeg", response.parsed_body["mimetype"]
assert_equal "moon.jpg", response.parsed_body["fileName"]
end
test "show" do
accounts("37s").uploads.attach fixture_file_upload("moon.jpg", "image/jpeg")
get upload_url(slug: accounts("37s").uploads.last.slug)
assert_response :redirect
assert_match /\/rails\/active_storage\/.*\/moon\.jpg/, @response.redirect_url
end
end
+14
View File
@@ -0,0 +1,14 @@
logo_agreement_jz:
record: logo_agreement_jz (Comment)
name: body
content: I agree.
logo_agreement_kevin:
record: logo_agreement_kevin (Comment)
name: body
content: Same, lets do it.
layout_overflowing_david:
record: layout_overflowing_david (Comment)
name: body
content: The text is overflowing the container.
-3
View File
@@ -1,13 +1,10 @@
logo_agreement_jz:
body: I agree.
creator: jz
created_at: <%= 2.days.ago %>
logo_agreement_kevin:
body: Same, lets do it.
creator: kevin
created_at: <%= 2.hours.ago %>
layout_overflowing_david:
body: The text is overflowing the container.
creator: david
+1 -1
View File
@@ -10,7 +10,7 @@ class BubbleTest < ActiveSupport::TestCase
bubbles(:logo).capture Comment.new(body: "Agreed.")
end
assert_equal "Agreed.", bubbles(:logo).messages.last.messageable.body
assert_equal "Agreed.", bubbles(:logo).messages.last.messageable.body.to_plain_text.chomp
end
test "boosting" do
View File
View File
File diff suppressed because one or more lines are too long