@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="comment__body txt-align-start">
|
||||
<%= simple_format @comment.body %>
|
||||
<%= sanitize @comment.body_html %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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 +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)}" }
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
Rails.application.config.after_initialize do
|
||||
%w[ db files ].each do |dir|
|
||||
Rails.root.join("storage", dir).mkpath
|
||||
end
|
||||
end
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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, let’s do it.
|
||||
|
||||
layout_overflowing_david:
|
||||
record: layout_overflowing_david (Comment)
|
||||
name: body
|
||||
content: The text is overflowing the container.
|
||||
Vendored
-3
@@ -1,13 +1,10 @@
|
||||
logo_agreement_jz:
|
||||
body: I agree.
|
||||
creator: jz
|
||||
created_at: <%= 2.days.ago %>
|
||||
|
||||
logo_agreement_kevin:
|
||||
body: Same, let’s do it.
|
||||
creator: kevin
|
||||
created_at: <%= 2.hours.ago %>
|
||||
|
||||
layout_overflowing_david:
|
||||
body: The text is overflowing the container.
|
||||
creator: david
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
Vendored
Vendored
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user