Merge branch 'main' into fix-blurred-nav-menu-in-safari
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
name: Sync Gemfile.saas.lock
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "dependabot/bundler/**"
|
||||
paths:
|
||||
- Gemfile.lock
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
|
||||
- name: Forward Gemfile.lock changes to Gemfile.saas.lock
|
||||
run: bin/bundle-drift forward
|
||||
|
||||
- name: Commit updated lockfile
|
||||
run: |
|
||||
git add Gemfile.saas.lock
|
||||
if ! git diff --cached --quiet; then
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git commit -m "Sync Gemfile.saas.lock"
|
||||
git push
|
||||
fi
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3.12.0
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3.6.0
|
||||
uses: docker/login-action@v3.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
|
||||
- name: Attest image provenance (per-arch)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/attest-build-provenance@v3.1.0
|
||||
uses: actions/attest-build-provenance@v3.2.0
|
||||
with:
|
||||
subject-name: ${{ steps.vars.outputs.canonical }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3.12.0
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3.6.0
|
||||
uses: docker/login-action@v3.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -25,7 +25,6 @@ gem "trilogy", "~> 2.9"
|
||||
gem "bcrypt", "~> 3.1.7"
|
||||
gem "geared_pagination", "~> 1.2"
|
||||
gem "rqrcode"
|
||||
gem "redcarpet"
|
||||
gem "rouge"
|
||||
gem "jbuilder"
|
||||
gem "lexxy", bc: "lexxy"
|
||||
@@ -41,6 +40,7 @@ gem "useragent", bc: "useragent"
|
||||
# Operations
|
||||
gem "autotuner"
|
||||
gem "mission_control-jobs"
|
||||
gem "stackprof"
|
||||
gem "benchmark" # indirect dependency, being removed from Ruby 3.5 stdlib so here to quash warnings
|
||||
|
||||
group :development, :test do
|
||||
|
||||
+8
-8
@@ -1,8 +1,8 @@
|
||||
GIT
|
||||
remote: https://github.com/basecamp/lexxy
|
||||
revision: 4f0fc4d5773bc6892de70f175440c259974c12a7
|
||||
revision: 4bcf8d0213971560e90734493f8bd835addc3642
|
||||
specs:
|
||||
lexxy (0.7.0.beta)
|
||||
lexxy (0.7.4.beta)
|
||||
rails (>= 8.0.2)
|
||||
|
||||
GIT
|
||||
@@ -147,9 +147,9 @@ GEM
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.0.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.21.1)
|
||||
bootsnap (1.22.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.1.2)
|
||||
brakeman (8.0.1)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bundler-audit (0.9.3)
|
||||
@@ -184,7 +184,7 @@ GEM
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
faker (3.5.3)
|
||||
faker (3.6.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
@@ -355,7 +355,6 @@ GEM
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
redcarpet (3.6.1)
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
@@ -400,7 +399,7 @@ GEM
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (3.2.2)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.39.0)
|
||||
selenium-webdriver (4.40.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
@@ -437,6 +436,7 @@ GEM
|
||||
net-sftp (>= 2.1.2)
|
||||
net-ssh (>= 2.8.0)
|
||||
ostruct
|
||||
stackprof (0.2.27)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.2.0)
|
||||
@@ -520,7 +520,6 @@ DEPENDENCIES
|
||||
puma (>= 5.0)
|
||||
rack-mini-profiler
|
||||
rails!
|
||||
redcarpet
|
||||
rouge
|
||||
rqrcode
|
||||
rubocop-rails-omakase
|
||||
@@ -529,6 +528,7 @@ DEPENDENCIES
|
||||
solid_cache (~> 1.0)
|
||||
solid_queue (~> 1.2)
|
||||
sqlite3 (>= 2.0)
|
||||
stackprof
|
||||
stimulus-rails
|
||||
thruster
|
||||
trilogy (~> 2.9)
|
||||
|
||||
@@ -24,3 +24,4 @@ gem "yabeda-puma-plugin"
|
||||
gem "yabeda-rails"
|
||||
gem "webrick" # required for yabeda-prometheus metrics server
|
||||
gem "prometheus-client-mmap", "~> 1.3"
|
||||
gem "gvltools"
|
||||
|
||||
+11
-9
@@ -13,7 +13,7 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/basecamp/console1984
|
||||
revision: 02b1b9ee7fd7050174b6a98c2b43057553621dc4
|
||||
revision: 23419ec8861e73f12ced75a3fc559d03f7be5854
|
||||
specs:
|
||||
console1984 (0.2.2)
|
||||
irb (~> 1.13)
|
||||
@@ -23,9 +23,9 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/basecamp/lexxy
|
||||
revision: 4f0fc4d5773bc6892de70f175440c259974c12a7
|
||||
revision: 4bcf8d0213971560e90734493f8bd835addc3642
|
||||
specs:
|
||||
lexxy (0.7.0.beta)
|
||||
lexxy (0.7.4.beta)
|
||||
rails (>= 8.0.2)
|
||||
|
||||
GIT
|
||||
@@ -227,9 +227,9 @@ GEM
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.0.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.21.1)
|
||||
bootsnap (1.22.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.1.2)
|
||||
brakeman (8.0.1)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bundler-audit (0.9.3)
|
||||
@@ -265,7 +265,7 @@ GEM
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
faker (3.5.3)
|
||||
faker (3.6.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
@@ -282,6 +282,7 @@ GEM
|
||||
addressable (>= 2.5.0)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
gvltools (0.4.0)
|
||||
hashdiff (1.2.1)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
@@ -466,7 +467,6 @@ GEM
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
redcarpet (3.6.1)
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
@@ -513,7 +513,7 @@ GEM
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (3.2.2)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.39.0)
|
||||
selenium-webdriver (4.40.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
@@ -558,6 +558,7 @@ GEM
|
||||
net-sftp (>= 2.1.2)
|
||||
net-ssh (>= 2.8.0)
|
||||
ostruct
|
||||
stackprof (0.2.27)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.2.0)
|
||||
@@ -657,6 +658,7 @@ DEPENDENCIES
|
||||
faker
|
||||
fizzy-saas!
|
||||
geared_pagination (~> 1.2)
|
||||
gvltools
|
||||
image_processing (~> 1.14)
|
||||
importmap-rails
|
||||
jbuilder
|
||||
@@ -675,7 +677,6 @@ DEPENDENCIES
|
||||
rack-mini-profiler
|
||||
rails!
|
||||
rails_structured_logging!
|
||||
redcarpet
|
||||
rouge
|
||||
rqrcode
|
||||
rubocop-rails-omakase
|
||||
@@ -686,6 +687,7 @@ DEPENDENCIES
|
||||
solid_cache (~> 1.0)
|
||||
solid_queue (~> 1.2)
|
||||
sqlite3 (>= 2.0)
|
||||
stackprof
|
||||
stimulus-rails
|
||||
stripe (~> 18.0)
|
||||
thruster
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
@layer components {
|
||||
.attachment {
|
||||
block-size: auto;
|
||||
display: block;
|
||||
inline-size: fit-content;
|
||||
position: relative;
|
||||
max-inline-size: 100%;
|
||||
|
||||
progress {
|
||||
inline-size: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment__caption {
|
||||
color: color-mix(in oklch, var(--color-ink) 66%, transparent);
|
||||
font-size: var(--text-small);
|
||||
|
||||
textarea {
|
||||
--input-border-radius: 0.3em;
|
||||
--input-border-size: 0;
|
||||
--input-padding: 0;
|
||||
|
||||
background-color: var(--input-background, transparent);
|
||||
border: none;
|
||||
color: inherit;
|
||||
inline-size: 100%;
|
||||
max-inline-size: 100%;
|
||||
resize: none;
|
||||
text-align: center;
|
||||
|
||||
&:focus {
|
||||
--focus-ring-size: 0;
|
||||
}
|
||||
|
||||
@supports (field-sizing: content) {
|
||||
field-sizing: content;
|
||||
inline-size: auto;
|
||||
min-inline-size: 20ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachment__icon {
|
||||
aspect-ratio: 4/5;
|
||||
background-color: color-mix(var(--attachment-icon-color), transparent 90%);
|
||||
block-size: 2.5lh;
|
||||
border: 2px solid var(--attachment-icon-color);
|
||||
border-block-start-width: 1ch;
|
||||
border-radius: 0.5ch;
|
||||
box-sizing: border-box;
|
||||
color: var(--attachment-icon-color);
|
||||
display: grid;
|
||||
font-size: var(--text-small);
|
||||
font-weight: bold;
|
||||
inline-size: auto;
|
||||
padding-inline: 0.5ch;
|
||||
place-content: center;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment--preview {
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
|
||||
img, video {
|
||||
block-size: auto;
|
||||
display: block;
|
||||
margin-inline: auto;
|
||||
max-inline-size: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.attachment__caption {
|
||||
column-gap: 0.5ch;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-block-start: 0.5ch;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment--file {
|
||||
--attachment-icon-color: var(--color-ink-medium);
|
||||
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1ch;
|
||||
inline-size: 100%;
|
||||
margin-inline: 0;
|
||||
|
||||
.attachment__caption {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.attachment__name {
|
||||
color: var(--color-ink);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Video attachments don't have an identifiable class, but we need to
|
||||
* make sure the caption is always below the video */
|
||||
&:has(video) {
|
||||
.attachment__caption {
|
||||
flex: none;
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachment--psd,
|
||||
.attachment--key,
|
||||
.attachment--sketch,
|
||||
.attachment--ai,
|
||||
.attachment--eps,
|
||||
.attachment--indd,
|
||||
.attachment--svg,
|
||||
.attachment--ppt,
|
||||
.attachment--pptx {
|
||||
--attachment-icon-color: oklch(var(--lch-red-medium));
|
||||
}
|
||||
|
||||
.attachment--css,
|
||||
.attachment--crash,
|
||||
.attachment--php,
|
||||
.attachment--json,
|
||||
.attachment--htm,
|
||||
.attachment--html,
|
||||
.attachment--rb,
|
||||
.attachment--erb,
|
||||
.attachment--ts,
|
||||
.attachment--js {
|
||||
--attachment-icon-color: oklch(var(--lch-purple-medium));
|
||||
}
|
||||
|
||||
.attachment--txt,
|
||||
.attachment--pages,
|
||||
.attachment--rtf,
|
||||
.attachment--md,
|
||||
.attachment--doc,
|
||||
.attachment--docx {
|
||||
--attachment-icon-color: oklch(var(--lch-blue-medium));
|
||||
}
|
||||
|
||||
.attachment--csv &,
|
||||
.attachment--numbers &,
|
||||
.attachment--xls &,
|
||||
.attachment--xlsx & {
|
||||
--attachment-icon-color: oklch(var(--lch-green-medium));
|
||||
}
|
||||
|
||||
.attachment__link {
|
||||
color: var(--color-link);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Custom attachments such as mentions, etc. */
|
||||
action-text-attachment[content-type^='application/vnd.actiontext'] {
|
||||
--attachment-bg-color: transparent;
|
||||
--attachment-image-size: 1em;
|
||||
--attachment-text-color: currentColor;
|
||||
|
||||
align-items: center;
|
||||
background: var(--attachment-bg-color);
|
||||
border-radius: 99rem;
|
||||
box-shadow:
|
||||
-0.25ch 0 0 var(--attachment-bg-color),
|
||||
0.5ch 0 0 var(--attachment-bg-color);
|
||||
color: var(--attachment-text-color);
|
||||
display: inline-flex;
|
||||
gap: 0.25ch;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
vertical-align: bottom;
|
||||
white-space: normal;
|
||||
|
||||
lexxy-editor & {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
img {
|
||||
block-size: var(--attachment-image-size);
|
||||
border-radius: 50%;
|
||||
inline-size: var(--attachment-image-size);
|
||||
}
|
||||
|
||||
&.node--selected {
|
||||
--attachment-bg-color: oklch(var(--lch-blue-dark));
|
||||
--attachment-text-color: var(--color-ink-inverted);
|
||||
}
|
||||
}
|
||||
|
||||
action-text-attachment[content-type^='application/vnd.actiontext.mention'] {
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
:is(a, button, input, textarea, .switch, .btn) {
|
||||
transition: 100ms ease-out;
|
||||
transition-property: background-color, border-color, box-shadow, filter, outline;
|
||||
transition-property: background-color, border-color, box-shadow, outline;
|
||||
touch-action: manipulation;
|
||||
|
||||
/* Keyboard navigation */
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
transition: 100ms ease-out;
|
||||
transition-property: background-color, border, box-shadow, color, filter, opacity, scale;
|
||||
transition-property: background-color, border, box-shadow, color, opacity, scale;
|
||||
|
||||
@media (any-hover: hover) {
|
||||
&:hover {
|
||||
|
||||
@@ -185,15 +185,16 @@
|
||||
.cards__transition-container {
|
||||
block-size: 100%;
|
||||
border-radius: calc(var(--column-width-collapsed) / 2);
|
||||
translate: 0 0.5ch; /* Allow a little room for the mini bubble */
|
||||
margin-block-start: 0.5ch; /* Allow a little room for the mini bubble */
|
||||
transition: translate var(--column-transition-duration) var(--ease-out-overshoot-subtle);
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.is-expanded & {
|
||||
translate: 0 0.5ch; /* Allow a little room for the mini bubble */
|
||||
translate: 0; /* Animate back from collapsed state */
|
||||
}
|
||||
|
||||
.is-collapsed & {
|
||||
margin-block-start: 0;
|
||||
translate: 0 var(--column-width-collapsed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
&:has(.card__closed) {
|
||||
padding-block-start: 2rem;
|
||||
}
|
||||
|
||||
&:has(.reactions .reaction) {
|
||||
.card__meta-avatars--assignees {
|
||||
margin-inline-end: unset;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:has(.reactions .reaction)) {
|
||||
&:has(.card__closed) {
|
||||
.card__meta-text--assignees {
|
||||
margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 799px) {
|
||||
--half-btn-height: 1.25rem;
|
||||
--padding-inline: 1.5ch;
|
||||
@@ -63,12 +83,33 @@
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.card__header {
|
||||
@media (max-width: 639px) {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--card-header-space) unset;
|
||||
}
|
||||
}
|
||||
|
||||
.card__tags {
|
||||
@media (max-width: 639px) {
|
||||
padding: 0.25lh;
|
||||
}
|
||||
}
|
||||
|
||||
.card__tags-list {
|
||||
@media (min-width: 640px) {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.card__body {
|
||||
position: relative;
|
||||
|
||||
@media (max-width: 639px) {
|
||||
flex-direction: column;
|
||||
padding-block-end: calc(var(--card-padding-block) * 1.5);
|
||||
padding-block: var(--card-padding-block) calc(var(--card-padding-block) * 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,12 +131,24 @@
|
||||
}
|
||||
|
||||
&:has(textarea) {
|
||||
margin-block-end: 0;
|
||||
@media (min-width: 640px) {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
@supports not (field-sizing: content) {
|
||||
text-wrap: unset; /* Safari is annoying if you have text-wrap: balance in textareas */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
margin-block-end: 0.75ch;
|
||||
}
|
||||
}
|
||||
|
||||
.card__description {
|
||||
@media (max-width: 639px) {
|
||||
margin-block-end: 1ch;
|
||||
}
|
||||
}
|
||||
|
||||
.card__meta,
|
||||
@@ -108,6 +161,43 @@
|
||||
.card__meta {
|
||||
grid-area: meta;
|
||||
margin-inline-end: auto;
|
||||
|
||||
@media (max-width: 639px) {
|
||||
--meta-spacer-block: 0.75ch;
|
||||
|
||||
inline-size: 100%;
|
||||
grid-template-areas:
|
||||
"avatars-author text-added"
|
||||
"avatars-author text-author"
|
||||
"text-updated text-updated"
|
||||
"text-assignees text-assignees"
|
||||
"avatars-assignees avatars-assignees";
|
||||
grid-template-columns: auto 1fr;
|
||||
|
||||
.card__meta-text {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card__meta-text + .card__meta-text {
|
||||
border-block-start: var(--card-border);
|
||||
margin-block-start: var(--meta-spacer-block);
|
||||
padding-block-start: var(--meta-spacer-block);
|
||||
}
|
||||
|
||||
.card__meta-avatars--assignees {
|
||||
margin-inline: 0 calc(var(--btn-size) + var(--meta-spacer-block));
|
||||
margin-block-start: var(--meta-spacer-block);
|
||||
}
|
||||
|
||||
.card__meta-avatars--author {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
.card__meta-avatars--assignees .avatar {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card__stages {
|
||||
@@ -115,12 +205,12 @@
|
||||
|
||||
@media (max-width: 639px) {
|
||||
border: 1px solid var(--card-color);
|
||||
border-radius: 0.25em;
|
||||
border-radius: calc(0.2em + 3px);
|
||||
flex-direction: row;
|
||||
gap: 0;
|
||||
overflow: auto;
|
||||
max-inline-size: 100%;
|
||||
padding: 0;
|
||||
padding: 3px;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -130,7 +220,7 @@
|
||||
min-block-size: 2.5em;
|
||||
}
|
||||
|
||||
& > form + form:not(:has(.card__column-name--current)) {
|
||||
& > form:not(:has(.card__column-name--current)) + form:not(:has(.card__column-name--current)) {
|
||||
box-shadow: -1px 0 0 0 var(--color-container);
|
||||
}
|
||||
}
|
||||
@@ -138,7 +228,6 @@
|
||||
|
||||
.card__column-name {
|
||||
@media (max-width: 639px) {
|
||||
--btn-border-radius: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -163,6 +252,7 @@
|
||||
|
||||
@media (max-width: 639px) {
|
||||
font-size: var(--text-x-small);
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,14 +279,24 @@
|
||||
inset: 0 calc(-1 * var(--card-padding-inline)) auto;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@media (any-hover: none) {
|
||||
--reaction-size: 2.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:has(.reaction)) {
|
||||
position: static;
|
||||
|
||||
.reactions__trigger {
|
||||
--btn-border-color: var(--color-ink-light);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -220,6 +220,15 @@
|
||||
.card__title-link {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--color-canvas);
|
||||
border: 1px solid var(--color-ink-lighter);
|
||||
border-radius: 0.25ch;
|
||||
font-family: var(--font-mono);
|
||||
font-size: smaller;
|
||||
padding: 0.1ch 0.25ch;
|
||||
}
|
||||
}
|
||||
|
||||
.card__description {
|
||||
@@ -229,7 +238,7 @@
|
||||
}
|
||||
|
||||
lexxy-toolbar {
|
||||
border-block-start: 1px solid var(--color-ink-light);
|
||||
border-block-start: 1px solid var(--lexxy-border-color);
|
||||
}
|
||||
|
||||
& ~ .btn.btn--reversed {
|
||||
@@ -372,6 +381,7 @@
|
||||
--stamp-color: oklch(var(--lch-green-medium) / 0.65);
|
||||
|
||||
align-items: center;
|
||||
backdrop-filter: blur(2px);
|
||||
background-color: color-mix(in srgb, var(--card-bg-color) 90%, transparent);
|
||||
border-radius: 0.2em;
|
||||
border: 0.5ch solid var(--stamp-color);
|
||||
@@ -399,6 +409,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
inset: auto calc(var(--card-padding-inline) * -0.5) 100% auto;
|
||||
translate: 0 40%;
|
||||
scale: 75%;
|
||||
}
|
||||
}
|
||||
|
||||
.card:has(.card__closed),
|
||||
|
||||
@@ -91,6 +91,17 @@
|
||||
.btn {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
margin-block-end: calc(var(--block-space-half) / 2);
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
column-gap: 0.4em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comment__avatar {
|
||||
@@ -155,7 +166,7 @@
|
||||
text-transform: capitalize;
|
||||
|
||||
@media (max-width: 639px) {
|
||||
line-height: 1.5lh;
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,32 +7,37 @@
|
||||
:is(.dialog) {
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
transform: scale(0.2);
|
||||
transform-origin: top center;
|
||||
transition: var(--dialog-duration) allow-discrete;
|
||||
transform: scale(0.85);
|
||||
transform-origin: center;
|
||||
transition-behavior: allow-discrete;
|
||||
transition-duration: calc(var(--dialog-duration) / 2); /* Faster closing */
|
||||
transition-property: display, opacity, overlay, transform;
|
||||
transition-timing-function: ease-out;
|
||||
|
||||
&::backdrop {
|
||||
background-color: var(--color-black);
|
||||
opacity: 0;
|
||||
transform: scale(1);
|
||||
transition: var(--dialog-duration) allow-discrete;
|
||||
transition-behavior: allow-discrete;
|
||||
transition-duration: calc(var(--dialog-duration) / 2);
|
||||
transition-property: display, opacity, overlay;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
&[open] {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transition-duration: var(--dialog-duration); /* Normal opening speed */
|
||||
|
||||
&::backdrop {
|
||||
opacity: 0.5;
|
||||
transition-duration: var(--dialog-duration);
|
||||
}
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
&[open] {
|
||||
opacity: 0;
|
||||
transform: scale(0.2);
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
&[open]::backdrop {
|
||||
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
@layer components {
|
||||
.import-status {
|
||||
--import-status-border-color: var(--color-ink-light);
|
||||
--import-status-color: var(--color-ink);
|
||||
|
||||
border: 1px dashed var(--import-status-border-color);
|
||||
border-radius: 1ch;
|
||||
color: var(--import-status-color);
|
||||
font-size: var(--text-medium);
|
||||
padding: 1.5ch;
|
||||
|
||||
.btn {
|
||||
font-size: var(--text-small);
|
||||
margin-block-start: 1.5ch;
|
||||
}
|
||||
}
|
||||
|
||||
.import-status--success {
|
||||
--import-status-border-color: var(--color-positive);
|
||||
--import-status-color: var(--color-positive);
|
||||
}
|
||||
|
||||
.import-status--error {
|
||||
--import-status-border-color: var(--color-negative);
|
||||
--import-status-color: var(--color-negative);
|
||||
}
|
||||
|
||||
@keyframes dash {
|
||||
to {
|
||||
background-position: 100% 0%, 0% 100%, 0% 0%, 100% 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
-469
@@ -1,515 +1,187 @@
|
||||
@import url("lexxy-variables.css") layer(base);
|
||||
@import url("lexxy-content.css") layer(base);
|
||||
@import url("lexxy-editor.css") layer(base);
|
||||
|
||||
:root {
|
||||
--lexxy-color-ink: var(--color-ink);
|
||||
--lexxy-color-ink-medium: var(--color-ink-dark);
|
||||
--lexxy-color-ink-light: var(--color-ink-medium);
|
||||
--lexxy-color-ink-lighter: var(--color-ink-light);
|
||||
--lexxy-color-ink-lightest: var(--color-ink-lighter);
|
||||
--lexxy-color-ink-inverted: var(--color-ink-inverted);
|
||||
|
||||
--lexxy-color-canvas: var(--color-canvas);
|
||||
|
||||
--lexxy-color-accent-dark: var(--color-accent-dark);
|
||||
--lexxy-color-accent-medium: var(--color-accent-medium);
|
||||
--lexxy-color-accent-light: var(--color-accent-light);
|
||||
--lexxy-color-accent-lightest: var(--color-accent-lightest);
|
||||
|
||||
--lexxy-color-red: oklch(var(--lch-red-medium));
|
||||
--lexxy-color-green: oklch(var(--lch-green-medium));
|
||||
--lexxy-color-blue: oklch(var(--lch-blue-medium));
|
||||
--lexxy-color-purple: oklch(var(--lch-purple-medium));
|
||||
|
||||
--lexxy-color-code-token-att: var(--color-code-token__att);
|
||||
--lexxy-color-code-token-comment: var(--color-code-token__comment);
|
||||
--lexxy-color-code-token-function: var(--color-code-token__function);
|
||||
--lexxy-color-code-token-operator: var(--color-code-token__operator);
|
||||
--lexxy-color-code-token-property: var(--color-code-token__property);
|
||||
--lexxy-color-code-token-punctuation: var(--color-code-token__punctuation);
|
||||
--lexxy-color-code-token-selector: var(--color-code-token__selector);
|
||||
--lexxy-color-code-token-variable: var(--color-code-token__variable);
|
||||
|
||||
--lexxy-color-selected: oklch(var(--lch-blue-light));
|
||||
--lexxy-color-selected-dark: oklch(var(--lch-blue-medium));
|
||||
|
||||
--lexxy-color-table-cell-border: var(--color-ink-ligher);
|
||||
--lexxy-color-table-cell-selected-bg: var(--lexxy-color-selected);
|
||||
--lexxy-color-table-cell-toggle: var(--lexxy-color-selected);
|
||||
--lexxy-color-table-cell-remove: oklch(var(--lch-red-medium) / 20%);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Editor
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
lexxy-editor {
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
--lexxy-border-color: oklch(var(--lch-ink-darkest) / 20%);
|
||||
--lexxy-editor-padding: 0;
|
||||
--lexxy-toolbar-button-size: 2rem;
|
||||
|
||||
figure.node--selected,
|
||||
div.node--selected {
|
||||
&:has(img) {
|
||||
img {
|
||||
outline: var(--focus-ring-size) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:has(img)) {
|
||||
outline: var(--focus-ring-size) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-content__table-wrapper {
|
||||
margin: 0;
|
||||
margin-block: 1ch;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
th, td {
|
||||
&.table-cell--selected {
|
||||
background-color: var(--color-selected-light);
|
||||
}
|
||||
|
||||
&.lexxy-content__table-cell--selected {
|
||||
background-color: var(--color-selected);
|
||||
border-color: var(--color-selected-dark);
|
||||
}
|
||||
}
|
||||
|
||||
&.lexxy-content__table--selection {
|
||||
::selection {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Lexical uses the `lexxy-editor--empty` class even if you have a list
|
||||
* started but haven't added other characters. Here, we hide the placeholder
|
||||
* when you click the List button in the toolbar. */
|
||||
.lexxy-editor--empty {
|
||||
.lexxy-editor__content:not(:has(ul, ol))::before {
|
||||
content: attr(placeholder);
|
||||
color: currentColor;
|
||||
cursor: text;
|
||||
opacity: 0.66;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
white-space: pre-line;
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-editor__content {
|
||||
margin-block-start: var(--block-space-half);
|
||||
min-block-size: calc(7lh + var(--block-space));
|
||||
outline: 0;
|
||||
|
||||
/* Allow color highlights to show through a bit */
|
||||
::selection {
|
||||
background: oklch(var(--lch-blue-light) / 0.5);
|
||||
}
|
||||
|
||||
> :last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-editor--drag-over {
|
||||
background-color: var(--color-selected);
|
||||
border-radius: 4px;
|
||||
outline: 2px dashed var(--color-selected-dark);
|
||||
}
|
||||
|
||||
.lexxy-code-language-picker {
|
||||
--caret-icon: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
|
||||
--caret-icon-dark: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
|
||||
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: var(--color-canvas);
|
||||
background-image: var(--caret-icon);
|
||||
background-position: center right 0.9em;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 0.5em;
|
||||
border: 1px solid var(--color-ink-light);
|
||||
border-radius: 99rem;
|
||||
color: var(--color-ink);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-base);
|
||||
font-size: var(--text-x-small);
|
||||
font-weight: 500;
|
||||
inset-inline-end: 0;
|
||||
line-height: 1.15lh;
|
||||
margin: 0.75ch 0.75ch 0 0;
|
||||
padding-inline: 1.5ch 1.8em;
|
||||
text-align: start;
|
||||
|
||||
html[data-theme="dark"] & {
|
||||
--caret-icon: var(--caret-icon-dark);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not([data-theme]) & {
|
||||
--caret-icon: var(--caret-icon-dark);
|
||||
}
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
}
|
||||
|
||||
/* Toolbar
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
/* TODO: Temporary - hide table button while we work on the styles */
|
||||
button[name="table"] {
|
||||
display: none;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
lexxy-toolbar {
|
||||
--lexxy-toolbar-icon-size: 1em;
|
||||
|
||||
background-color: var(--lexxy-bg-color, var(--color-canvas));
|
||||
border-block-end: 1px solid var(--color-ink-light);
|
||||
color: currentColor;
|
||||
display: flex;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
max-inline-size: 100%;
|
||||
padding: 0.2em 0;
|
||||
position: relative;
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
z-index: 2;
|
||||
border-color: var(--lexxy-border-color);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.lexxy-editor__toolbar-button {
|
||||
--toolbar-button-size: 44px;
|
||||
background: transparent;
|
||||
|
||||
appearance: none;
|
||||
aspect-ratio: 1;
|
||||
background-color: transparent;
|
||||
block-size: var(--toolbar-button-size);
|
||||
border: none;
|
||||
border-radius: 0.2em;
|
||||
color: currentColor;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
font-size: inherit;
|
||||
place-items: center;
|
||||
|
||||
&:is(:focus, :hover) {
|
||||
background-color: var(--color-ink-lighter);
|
||||
box-shadow: none;
|
||||
&[aria-pressed="true"],
|
||||
[open] > & {
|
||||
background-color: oklch(var(--lch-blue-medium) / 20%);
|
||||
}
|
||||
|
||||
&:is(:active),
|
||||
&[aria-pressed="true"] {
|
||||
background-color: var(--color-selected);
|
||||
}
|
||||
|
||||
svg {
|
||||
-webkit-touch-callout: none;
|
||||
block-size: var(--lexxy-toolbar-icon-size);
|
||||
fill: currentColor;
|
||||
grid-area: 1/1;
|
||||
inline-size: var(--lexxy-toolbar-icon-size);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
--toolbar-button-size: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-editor__toolbar-overflow-menu {
|
||||
background-color: var(--color-canvas);
|
||||
border-radius: 0.5ch;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
inset-inline-end: 0;
|
||||
padding: 4px;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.lexxy-editor__toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Dropdowns
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.lexxy-editor__toolbar-dropdown-content {
|
||||
--lexxy-dropdown-padding: 0.75rem;
|
||||
--lexxy-dropdown-btn-size: 2rem;
|
||||
|
||||
background-color: var(--color-canvas);
|
||||
border: 1px solid var(--color-ink-lighter);
|
||||
border-radius: 0.5em;
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--color-ink);
|
||||
font-size: var(--text-small);
|
||||
padding: var(--lexxy-dropdown-padding);
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
|
||||
button {
|
||||
block-size: var(--lexxy-dropdown-btn-size);
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-editor__toolbar-dropdown-actions {
|
||||
display: flex;
|
||||
font-size: var(--text-x-small);
|
||||
flex: 1 1 0%;
|
||||
gap: var(--lexxy-dropdown-padding);
|
||||
margin-block-start: var(--lexxy-dropdown-padding);
|
||||
|
||||
.btn {
|
||||
--radius: 99rem;
|
||||
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.btn[type="submit"] {
|
||||
--btn-background: var(--card-color, var(--color-link));
|
||||
--btn-color: var(--color-ink-inverted);
|
||||
--focus-ring-color: var(--card-color, var(--color-link));
|
||||
}
|
||||
|
||||
span {
|
||||
inline-size: 100%;
|
||||
@media(any-hover: hover) {
|
||||
&:hover:not([aria-pressed="true"]) {
|
||||
background-color: oklch(var(--lch-ink-dark) / 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lexxy-link-dropdown {
|
||||
.input {
|
||||
min-inline-size: 30ch;
|
||||
}
|
||||
}
|
||||
--lexxy-toolbar-spacing: 1.5ch;
|
||||
|
||||
lexxy-highlight-dropdown {
|
||||
--gap: 0.5ch;
|
||||
.lexxy-editor__toolbar-button {
|
||||
border-radius: 99rem;
|
||||
|
||||
[data-button-group] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap);
|
||||
|
||||
+ & {
|
||||
margin-block-start: var(--gap);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-highlight-button {
|
||||
--outline-color: oklch(var(--lch-ink-darkest) / 0.2);
|
||||
|
||||
appearance: none;
|
||||
background: var(--color-canvas);
|
||||
border: none;
|
||||
border-radius: 0.5ch;
|
||||
color: inherit;
|
||||
display: grid;
|
||||
font-weight: 500;
|
||||
inline-size: var(--lexxy-dropdown-btn-size);
|
||||
place-content: center;
|
||||
position: relative;
|
||||
outline: none;
|
||||
|
||||
&:after {
|
||||
content: "Aa";
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[aria-pressed="true"] {
|
||||
box-shadow: 0 0 0 1px var(--color-canvas), 0 0 0 3px var(--outline-color);
|
||||
}
|
||||
|
||||
&[aria-pressed="true"] {
|
||||
--outline-color: var(--color-link);
|
||||
|
||||
&:after {
|
||||
content: "✓";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-editor__toolbar-dropdown-reset {
|
||||
background: var(--color-canvas);
|
||||
border: 1px solid var(--color-ink-light);
|
||||
border-radius: 99rem;
|
||||
inline-size: 100%;
|
||||
margin-block-start: var(--lexxy-dropdown-padding);
|
||||
|
||||
&[disabled] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-table-handle-buttons {
|
||||
--button-size: 2.5em;
|
||||
|
||||
color: var(--color-ink-inverted);
|
||||
display: none;
|
||||
flex-direction: row;
|
||||
font-size: var(--text-x-small);
|
||||
gap: 0.25ch;
|
||||
line-height: 1;
|
||||
position: absolute;
|
||||
transform: translate(-50%, -120%);
|
||||
z-index: 1;
|
||||
|
||||
&:has([open]) {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.lexxy-table-control {
|
||||
align-items: center;
|
||||
background-color: var(--color-ink);
|
||||
border-radius: 0.75ch;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1ch;
|
||||
padding: 2px;
|
||||
white-space: nowrap;
|
||||
|
||||
button,
|
||||
summary {
|
||||
aspect-ratio: 1 / 1;
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
border-radius: 0.5ch;
|
||||
border: 0;
|
||||
color: var(--color-ink-inverted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: inherit;
|
||||
font-weight: 700;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
list-style: none;
|
||||
min-block-size: var(--button-size);
|
||||
min-inline-size: var(--button-size);
|
||||
padding: 0;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
@media(any-hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-ink-darker);
|
||||
filter: brightness(0.9);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
background-color: var(--color-ink-darker);
|
||||
outline: var(--focus-ring-size) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
svg {
|
||||
block-size: 1em;
|
||||
inline-size: 1em;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
&[type="submit"],
|
||||
&[type="submit"]:hover {
|
||||
background-color: var(--color-link);
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-table-control__more-menu {
|
||||
gap: 0;
|
||||
padding: 2px;
|
||||
position: relative;
|
||||
|
||||
summary {
|
||||
&::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-table-control__more-menu-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25ch;
|
||||
inset-block-start: 105%;
|
||||
inset-inline-end: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
|
||||
.lexxy-table-control__more-menu-section {
|
||||
background: var(--color-ink);
|
||||
border-radius: 0.75ch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
button {
|
||||
aspect-ratio: unset;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
font-weight: normal;
|
||||
gap: 1ch;
|
||||
justify-content: flex-start;
|
||||
padding: 0.5ch 2ch;
|
||||
padding-inline-start: 1ch;
|
||||
white-space: nowrap;
|
||||
|
||||
svg {
|
||||
block-size: 1.3em;
|
||||
inline-size: 1.3em;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&[type="button"] {
|
||||
border: 1px solid var(--color-ink-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Prompt menu (@mentions, etc.)
|
||||
.lexxy-editor__content {
|
||||
margin-block-start: 0.5lh;
|
||||
}
|
||||
|
||||
.lexxy-code-language-picker {
|
||||
border-radius: 99rem;
|
||||
}
|
||||
|
||||
lexxy-table-tools {
|
||||
font-size: var(--text-x-small);
|
||||
}
|
||||
|
||||
[data-lexical-cursor] {
|
||||
animation: blink 1s step-end infinite;
|
||||
block-size: 1lh;
|
||||
border-inline-start: 1px solid currentColor;
|
||||
line-height: inherit;
|
||||
margin-block: 1em;
|
||||
}
|
||||
|
||||
/* Content
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.lexxy-prompt-menu {
|
||||
--lexxy-prompt-avatar-size: 24px;
|
||||
--lexxy-prompt-min-width: 20ch;
|
||||
--lexxy-prompt-padding: 0.5ch;
|
||||
.lexxy-content {
|
||||
--lexxy-content-margin: 0.5lh;
|
||||
|
||||
background-color: var(--color-canvas);
|
||||
border-radius: calc(var(--lexxy-prompt-padding) * 2);
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-small);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
max-height: 200px;
|
||||
min-inline-size: var(--lexxy-prompt-min-width);
|
||||
overflow: auto;
|
||||
padding: var(--lexxy-prompt-padding);
|
||||
visibility: hidden;
|
||||
z-index: var(--z-popup);
|
||||
}
|
||||
color: currentColor;
|
||||
|
||||
.lexxy-prompt-menu--visible {
|
||||
visibility: initial;
|
||||
}
|
||||
|
||||
.lexxy-prompt-menu__item {
|
||||
align-items: center;
|
||||
border-radius: 0.5ch;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: var(--lexxy-prompt-padding);
|
||||
padding: var(--lexxy-prompt-padding);
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-ink-lightest);
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02ch;
|
||||
line-height: 1.1;
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
&[aria-selected] {
|
||||
background-color: var(--color-selected);
|
||||
}
|
||||
|
||||
img {
|
||||
block-size: var(--lexxy-prompt-avatar-size);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
inline-size: var(--lexxy-prompt-avatar-size);
|
||||
p:has(+ p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
+ & {
|
||||
margin-top: 2px;
|
||||
ol, ul {
|
||||
padding-inline-start: 1ch;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-inline-start: 0.25em solid var(--color-ink-lighter);
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--color-terminal-text-light);
|
||||
border-radius: 0.25ch;
|
||||
font-size: 0.95em;
|
||||
padding-inline: 0.5ch;
|
||||
background: var(--color-canvas);
|
||||
border: 1px solid var(--color-ink-lighter);
|
||||
}
|
||||
|
||||
.horizontal-divider {
|
||||
padding-block: var(--lexxy-content-margin);
|
||||
hr { margin: 0; }
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
border-block-end: 2px solid currentColor;
|
||||
color: currentColor;
|
||||
inline-size: 20%;
|
||||
margin: calc(var(--lexxy-content-margin) * 2) 0;
|
||||
}
|
||||
|
||||
table {
|
||||
th, td {
|
||||
font-size: var(--text-small);
|
||||
padding-block: 0.75ch;
|
||||
}
|
||||
tr:not([data-action="delete"]) {
|
||||
th:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-ink-lightest); }
|
||||
td:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-canvas); }
|
||||
}
|
||||
}
|
||||
|
||||
.attachment {
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.lexxy-prompt-menu__item--empty {
|
||||
color: var(--color-ink-medium);
|
||||
padding: var(--lexxy-prompt-padding);
|
||||
.attachment {
|
||||
margin-inline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,12 @@
|
||||
translate: unset;
|
||||
}
|
||||
|
||||
.card-perma--draft {
|
||||
.card-perma__bg {
|
||||
box-shadow: 0 calc(100vh + 100px) 0 100vh var(--color-container);
|
||||
}
|
||||
}
|
||||
|
||||
/* Search
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
/* Help
|
||||
/* ------------------------------------------------------------------------ */
|
||||
.notifications-help {
|
||||
h2 {
|
||||
h3 {
|
||||
font-size: var(--text-medium);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -95,12 +95,12 @@
|
||||
display: none;
|
||||
|
||||
.notifications--on & {
|
||||
display: inline;
|
||||
display: revert;
|
||||
}
|
||||
}
|
||||
|
||||
.notifications__off-message {
|
||||
display: inline;
|
||||
display: revert;
|
||||
|
||||
.notifications--on & {
|
||||
display: none;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.performance-notice {
|
||||
background: oklch(var(--lch-yellow-lightest));
|
||||
border-radius: 1ch;
|
||||
border: 1px solid oklch(var(--lch-yellow-light));
|
||||
font-size: var(--text-small);
|
||||
margin-block-end: 2ch;
|
||||
margin-inline: auto;
|
||||
max-inline-size: 60ch;
|
||||
padding-inline: 2ch;
|
||||
padding: 1ch;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
@layer components {
|
||||
.qr-code {
|
||||
aspect-ratio: 1;
|
||||
block-size: 50dvh;
|
||||
border-radius: 1ch;
|
||||
inline-size: clamp(20ch, 50dvh, 70ch);
|
||||
margin-block: var(--block-space);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,10 @@
|
||||
font-size: 0.6em;
|
||||
inline-size: auto;
|
||||
}
|
||||
|
||||
@media (any-hover: none) {
|
||||
padding-inline-end: 0.12em;
|
||||
}
|
||||
}
|
||||
|
||||
.reaction--deleteable {
|
||||
@@ -166,6 +170,11 @@
|
||||
.reaction__cancel-btn {
|
||||
--btn-size: 1.25rem;
|
||||
--icon-size: var(--btn-size);
|
||||
|
||||
@media (any-hover: none) {
|
||||
--btn-size: 2rem;
|
||||
--icon-size: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
.reaction__submit-btn {
|
||||
@@ -219,6 +228,12 @@
|
||||
&:hover {
|
||||
scale: 1.3;
|
||||
}
|
||||
|
||||
@media (any-hover: none) {
|
||||
--btn-size: calc(1.6rem * 1.3);
|
||||
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,469 +0,0 @@
|
||||
@layer components {
|
||||
:where(.rich-text-content) {
|
||||
--block-margin: 0.5lh;
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
display: block;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02ch;
|
||||
line-height: 1.1;
|
||||
margin-block: 0 var(--block-margin);
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
p, ul, ol, dl, blockquote, figure, .attachment {
|
||||
margin-block: 0 var(--block-margin);
|
||||
|
||||
&:not(lexxy-editor &) {
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
}
|
||||
|
||||
p:has(+ p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ol, ul {
|
||||
padding-inline-start: 3ch;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
li:has(li) {
|
||||
list-style: none;
|
||||
|
||||
ol, ul {
|
||||
margin: 0;
|
||||
padding-inline-start: 2ch;
|
||||
}
|
||||
}
|
||||
|
||||
b, strong, .lexxy-content__bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
i, em, .lexxy-content__italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
s, .lexxy-content__strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
mark, .lexxy-content__highlight {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
p, blockquote {
|
||||
letter-spacing: -0.005ch;
|
||||
}
|
||||
|
||||
/* Avoid extra space due to empty paragraphs */
|
||||
p:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-inline-start: 0.25em solid var(--color-ink-lighter);
|
||||
font-style: italic;
|
||||
margin: var(--block-margin) 0;
|
||||
padding-inline-start: 2ch;
|
||||
}
|
||||
|
||||
img, video, embed, object {
|
||||
inline-size: auto;
|
||||
margin-inline: auto;
|
||||
max-block-size: 32rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Links should hug media contained within */
|
||||
a:has(img),
|
||||
a:has(video) {
|
||||
inline-size: fit-content;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
hr {
|
||||
color: currentColor;
|
||||
block-size: 0;
|
||||
border: none;
|
||||
border-block-end: 2px solid currentColor;
|
||||
inline-size: 20%;
|
||||
margin: calc(var(--block-margin) * 2) 0;
|
||||
}
|
||||
|
||||
.horizontal-divider {
|
||||
margin: var(--block-margin) 0;
|
||||
padding-block: var(--block-margin);
|
||||
}
|
||||
|
||||
/* Code */
|
||||
code, pre {
|
||||
background-color: var(--color-canvas);
|
||||
border: 1px solid var(--color-ink-lighter);
|
||||
border-radius: 0.3em;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
margin-block: var(--block-margin);
|
||||
padding: 0.1em 0.3em;
|
||||
|
||||
&:is(pre),
|
||||
&[data-language] {
|
||||
border-radius: 0.5em;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 0.5lh 2ch;
|
||||
tab-size: 2;
|
||||
text-wrap: nowrap;
|
||||
white-space: pre;
|
||||
word-break: break-word;
|
||||
|
||||
/* Keywords and attributes */
|
||||
.code-token__attr,
|
||||
.token.atrule,
|
||||
.token.attr,
|
||||
.token.keyword {
|
||||
color: var(--color-code-token__att);
|
||||
}
|
||||
|
||||
/* Constants, booleans, numbers, properties, tags */
|
||||
.code-token__property,
|
||||
.token.boolean,
|
||||
.token.constant,
|
||||
.token.deleted,
|
||||
.token.number,
|
||||
.token.property,
|
||||
.token.symbol,
|
||||
.token.tag {
|
||||
color: var(--color-code-token__property);
|
||||
}
|
||||
|
||||
/* Strings, selectors, and built-in constructs */
|
||||
.code-token__selector,
|
||||
.token.builtin,
|
||||
.token.char,
|
||||
.token.inserted,
|
||||
.token.selector,
|
||||
.token.string {
|
||||
color: var(--color-code-token__selector);
|
||||
}
|
||||
|
||||
/* Comments and meta information */
|
||||
.code-token__comment,
|
||||
.token.cdata,
|
||||
.token.comment,
|
||||
.token.doctype,
|
||||
.token.prolog {
|
||||
color: var(--color-code-token__comment);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Operators and symbolic entities */
|
||||
.code-token__operator,
|
||||
.token.entity,
|
||||
.token.operator,
|
||||
.token.url {
|
||||
color: var(--color-code-token__operator);
|
||||
}
|
||||
|
||||
/* Functions and class names */
|
||||
.code-token__function,
|
||||
.token.class,
|
||||
.token.class-name,
|
||||
.token.function {
|
||||
color: var(--color-code-token__function);
|
||||
}
|
||||
|
||||
/* Variables, regex, namespaces, important */
|
||||
.code-token__variable,
|
||||
.token.important,
|
||||
.token.namespace,
|
||||
.token.regex,
|
||||
.token.variable {
|
||||
color: var(--color-code-token__variable);
|
||||
}
|
||||
|
||||
/* Punctuation */
|
||||
.code-token__punctuation,
|
||||
.token.punctuation {
|
||||
color: var(--color-code-token__punctuation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.lexxy-content__table-wrapper {
|
||||
margin: 0;
|
||||
margin-block: 1ch;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
inline-size: calc(100% - 0.5ch);
|
||||
margin: 0.25ch;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--color-ink-light);
|
||||
padding: 0.5ch 1ch;
|
||||
text-align: start;
|
||||
word-break: normal;
|
||||
|
||||
*:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
*:not(pre, code) {
|
||||
word-break: normal;
|
||||
}
|
||||
}
|
||||
|
||||
th,
|
||||
.lexxy-content__table-cell--header {
|
||||
background: var(--color-ink-lightest);
|
||||
}
|
||||
|
||||
td {
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Attachments
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.attachment {
|
||||
block-size: auto;
|
||||
display: block;
|
||||
inline-size: fit-content;
|
||||
position: relative;
|
||||
max-inline-size: 100%;
|
||||
|
||||
progress {
|
||||
inline-size: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment__caption {
|
||||
color: color-mix(in oklch, var(--color-ink) 66%, transparent);
|
||||
font-size: var(--text-small);
|
||||
|
||||
textarea {
|
||||
--input-border-radius: 0.3em;
|
||||
--input-border-size: 0;
|
||||
--input-padding: 0;
|
||||
|
||||
background-color: var(--input-background, transparent);
|
||||
border: none;
|
||||
color: inherit;
|
||||
inline-size: 100%;
|
||||
max-inline-size: 100%;
|
||||
resize: none;
|
||||
text-align: center;
|
||||
|
||||
&:focus {
|
||||
--focus-ring-size: 0;
|
||||
}
|
||||
|
||||
@supports (field-sizing: content) {
|
||||
field-sizing: content;
|
||||
inline-size: auto;
|
||||
min-inline-size: 20ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachment__icon {
|
||||
aspect-ratio: 4/5;
|
||||
background-color: color-mix(var(--attachment-icon-color), transparent 90%);
|
||||
block-size: 2.5lh;
|
||||
border: 2px solid var(--attachment-icon-color);
|
||||
border-block-start-width: 1ch;
|
||||
border-radius: 0.5ch;
|
||||
box-sizing: border-box;
|
||||
color: var(--attachment-icon-color);
|
||||
display: grid;
|
||||
font-size: var(--text-small);
|
||||
font-weight: bold;
|
||||
inline-size: auto;
|
||||
padding-inline: 0.5ch;
|
||||
place-content: center;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment--preview {
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
|
||||
img, video {
|
||||
block-size: auto;
|
||||
display: block;
|
||||
margin-inline: auto;
|
||||
max-inline-size: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.attachment__caption {
|
||||
column-gap: 0.5ch;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-block-start: 0.5ch;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment--file {
|
||||
--attachment-icon-color: var(--color-ink-medium);
|
||||
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1ch;
|
||||
inline-size: 100%;
|
||||
margin-inline: 0;
|
||||
|
||||
.attachment__caption {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.attachment__name {
|
||||
color: var(--color-ink);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Video attachments don't have an identifiable class, but we need to
|
||||
* make sure the caption is always below the video */
|
||||
&:has(video) {
|
||||
.attachment__caption {
|
||||
flex: none;
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachment--psd,
|
||||
.attachment--key,
|
||||
.attachment--sketch,
|
||||
.attachment--ai,
|
||||
.attachment--eps,
|
||||
.attachment--indd,
|
||||
.attachment--svg,
|
||||
.attachment--ppt,
|
||||
.attachment--pptx {
|
||||
--attachment-icon-color: oklch(var(--lch-red-medium));
|
||||
}
|
||||
|
||||
.attachment--css,
|
||||
.attachment--crash,
|
||||
.attachment--php,
|
||||
.attachment--json,
|
||||
.attachment--htm,
|
||||
.attachment--html,
|
||||
.attachment--rb,
|
||||
.attachment--erb,
|
||||
.attachment--ts,
|
||||
.attachment--js {
|
||||
--attachment-icon-color: oklch(var(--lch-purple-medium));
|
||||
}
|
||||
|
||||
.attachment--txt,
|
||||
.attachment--pages,
|
||||
.attachment--rtf,
|
||||
.attachment--md,
|
||||
.attachment--doc,
|
||||
.attachment--docx {
|
||||
--attachment-icon-color: oklch(var(--lch-blue-medium));
|
||||
}
|
||||
|
||||
.attachment--csv &,
|
||||
.attachment--numbers &,
|
||||
.attachment--xls &,
|
||||
.attachment--xlsx & {
|
||||
--attachment-icon-color: oklch(var(--lch-green-medium));
|
||||
}
|
||||
|
||||
.attachment__link {
|
||||
color: var(--color-link);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Custom attachments such as mentions, etc. */
|
||||
action-text-attachment[content-type^='application/vnd.actiontext'] {
|
||||
--attachment-bg-color: transparent;
|
||||
--attachment-image-size: 1em;
|
||||
--attachment-text-color: currentColor;
|
||||
|
||||
align-items: center;
|
||||
background: var(--attachment-bg-color);
|
||||
border-radius: 99rem;
|
||||
box-shadow:
|
||||
-0.25ch 0 0 var(--attachment-bg-color),
|
||||
0.5ch 0 0 var(--attachment-bg-color);
|
||||
color: var(--attachment-text-color);
|
||||
display: inline-flex;
|
||||
gap: 0.25ch;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
vertical-align: bottom;
|
||||
white-space: normal;
|
||||
|
||||
lexxy-editor & {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
img {
|
||||
block-size: var(--attachment-image-size);
|
||||
border-radius: 50%;
|
||||
inline-size: var(--attachment-image-size);
|
||||
}
|
||||
|
||||
&.node--selected {
|
||||
--attachment-bg-color: oklch(var(--lch-blue-dark));
|
||||
--attachment-text-color: var(--color-ink-inverted);
|
||||
}
|
||||
}
|
||||
|
||||
action-text-attachment[content-type^='application/vnd.actiontext.mention'] {
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
[data-lexical-cursor] {
|
||||
animation: blink 1s step-end infinite;
|
||||
block-size: 1lh;
|
||||
border-inline-start: 1px solid currentColor;
|
||||
line-height: inherit;
|
||||
margin-block: 1em;
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,14 @@
|
||||
/* Users
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.settings__section--users {
|
||||
@media (min-width: 640px) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
.settings__user-filter {
|
||||
--btn-size: 3.5ch;
|
||||
--avatar-size: var(--btn-size);
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
.txt-link { color: var(--color-link); text-decoration: underline; }
|
||||
|
||||
.font-weight-normal { font-weight: normal; }
|
||||
.font-weight-semibold { font-weight: 600; }
|
||||
.font-weight-bold { font-weight: bold; }
|
||||
.font-weight-black { font-weight: 900; }
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ class Cards::ReadingsController < ApplicationController
|
||||
include CardScoped
|
||||
|
||||
def create
|
||||
@notifications = @card.read_by(Current.user)
|
||||
@notification = @card.read_by(Current.user)
|
||||
record_board_access
|
||||
end
|
||||
|
||||
def destroy
|
||||
@notifications = @card.unread_by(Current.user)
|
||||
@notification = @card.unread_by(Current.user)
|
||||
record_board_access
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class Notifications::TraysController < ApplicationController
|
||||
def show
|
||||
@notifications = Current.user.notifications.preloaded.unread.ordered.limit(100)
|
||||
@notifications = Current.user.notifications.unread.preloaded.ordered.limit(100)
|
||||
|
||||
# Invalidate on the whole set instead of the unread set since the max updated at in the unread set
|
||||
# can stay the same when reading old notifications.
|
||||
|
||||
@@ -6,7 +6,7 @@ class QrCodesController < ApplicationController
|
||||
|
||||
qr_code_svg = RQRCode::QRCode
|
||||
.new(QrCodeLink.from_signed(params[:id]).url)
|
||||
.as_svg(viewbox: true, fill: :white, color: :black)
|
||||
.as_svg(viewbox: true, fill: :white, color: :black, offset: 16)
|
||||
|
||||
render svg: qr_code_svg
|
||||
end
|
||||
|
||||
@@ -9,4 +9,12 @@ module BoardsHelper
|
||||
icon_tag("settings") + tag.span("Settings for #{board.name}", class: "for-screen-reader")
|
||||
end
|
||||
end
|
||||
|
||||
def bridged_button_to_board(board)
|
||||
link_to "Go to #{board.name}", board, hidden: true, data: {
|
||||
bridge__buttons_target: "button",
|
||||
bridge_icon_url: bridge_icon("board"),
|
||||
bridge_title: "Go to #{board.name}"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,7 @@ module ColumnsHelper
|
||||
card_triage_path(card, column_id: column),
|
||||
method: :post,
|
||||
class: [ "card__column-name btn", { "card__column-name--current": column == card.column && card.open? } ],
|
||||
disabled: column == card.column,
|
||||
disabled: column == card.column && card.open?,
|
||||
style: "--column-color: #{column.color}",
|
||||
form_class: "flex gap-half",
|
||||
data: { turbo_frame: "_top", scroll_to_target: column == card.column && card.open? ? "target" : nil }
|
||||
|
||||
@@ -2,4 +2,10 @@ module HtmlHelper
|
||||
def format_html(html)
|
||||
Loofah::HTML5::DocumentFragment.parse(html).scrub!(AutoLinkScrubber.new).to_html.html_safe
|
||||
end
|
||||
|
||||
def card_html_title(card)
|
||||
return card.title if card.title.blank?
|
||||
|
||||
ERB::Util.html_escape(card.title).gsub(/`([^`]+)`/, '<code>\1</code>').html_safe
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,9 +29,7 @@ module NotificationsHelper
|
||||
def notification_tag(notification, &)
|
||||
tag.div id: dom_id(notification), class: "tray__item tray__item--notification", data: {
|
||||
navigable_list_target: "item",
|
||||
notifications_tray_target: "notification",
|
||||
card_id: notification.card.id,
|
||||
timestamp: notification.created_at.to_i
|
||||
card_id: notification.card.id
|
||||
} do
|
||||
link_to(notification,
|
||||
class: [ "card card--notification", { "card--closed": notification.card.closed? }, { "unread": !notification.read? } ],
|
||||
@@ -58,7 +56,7 @@ module NotificationsHelper
|
||||
data: { action: "form#submit:stop badge#update:stop", form_target: "submit" },
|
||||
form: { data: { controller: "form" } } do
|
||||
concat(icon_tag("remove"))
|
||||
concat(tag.span("1", class: "badge-count", data: { group_count: "" }))
|
||||
concat(tag.span(notification.unread_count, class: "badge-count")) if notification.unread_count > 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,11 +15,7 @@ module RichTextHelper
|
||||
content_tag "lexxy-prompt", "", trigger: "#", src: prompts_cards_path, name: "card", "insert-editable-text": true, "remote-filtering": true, "supports-space-in-searches": true
|
||||
end
|
||||
|
||||
def code_language_picker
|
||||
content_tag "lexxy-code-language-picker"
|
||||
end
|
||||
|
||||
def general_prompts(board)
|
||||
safe_join([ mentions_prompt(board), cards_prompt, code_language_picker ])
|
||||
safe_join([ mentions_prompt(board), cards_prompt ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { nextFrame, debounce } from "helpers/timing_helpers";
|
||||
import { isNative } from "helpers/platform_helpers";
|
||||
|
||||
export default class extends Controller {
|
||||
static classes = [ "collapsed", "expanded", "noTransitions", "titleNotVisible" ]
|
||||
@@ -49,7 +50,7 @@ export default class extends Controller {
|
||||
focusOnColumn({ target }) {
|
||||
if (this.#isDesktop && this.#isCollapsed(target)) {
|
||||
this.#collapseAllExcept(target)
|
||||
this.#expand(target)
|
||||
this.#expand({ column: target })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +75,7 @@ export default class extends Controller {
|
||||
this.#collapseAllExcept(column)
|
||||
|
||||
if (this.#isCollapsed(column)) {
|
||||
this.#expand(column)
|
||||
this.#expand({ column })
|
||||
} else {
|
||||
this.#collapse(column)
|
||||
}
|
||||
@@ -103,7 +104,7 @@ export default class extends Controller {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
|
||||
#expand(column, saveState = true) {
|
||||
#expand({ column, saveState = true, scrollBehavior = "smooth" }) {
|
||||
this.#buttonFor(column)?.setAttribute("aria-expanded", "true")
|
||||
column.classList.remove(this.collapsedClass)
|
||||
column.classList.add(this.expandedClass)
|
||||
@@ -114,7 +115,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
if (window.matchMedia('(max-width: 639px)').matches) {
|
||||
column.scrollIntoView({ behavior: "smooth", inline: "center" })
|
||||
column.scrollIntoView({ behavior: scrollBehavior, inline: "center" })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +133,7 @@ export default class extends Controller {
|
||||
const key = this.#localStorageKeyFor(column)
|
||||
if (localStorage.getItem(key)) {
|
||||
this.#collapseAllExcept(column)
|
||||
this.#expand(column)
|
||||
this.#expand({ column, scrollBehavior: isNative() ? "instant" : "smooth" })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +169,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
async #handleDesktopMode() {
|
||||
this.#expand(this.maybeColumnTarget, false)
|
||||
this.#expand({ column: this.maybeColumnTarget, saveState: false })
|
||||
this.#maybeButton.setAttribute("disabled", true)
|
||||
}
|
||||
|
||||
@@ -181,7 +182,7 @@ export default class extends Controller {
|
||||
this.#collapseAllExcept(expandedColumn)
|
||||
} else {
|
||||
this.#collapseAllExcept(this.maybeColumnTarget)
|
||||
this.#expand(this.maybeColumnTarget, false)
|
||||
this.#expand({ column: this.maybeColumnTarget, saveState: false })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ export default class extends Controller {
|
||||
static values = { autoExpandSelector: String }
|
||||
|
||||
connect() {
|
||||
if (this.hasAutoExpandSelectorValue && this.element.querySelector(this.autoExpandSelectorValue)) {
|
||||
this.element.open = true
|
||||
}
|
||||
this.element.open = this.hasAutoExpandSelectorValue && this.element.querySelector(this.autoExpandSelectorValue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "notification", "hiddenNotifications" ]
|
||||
static classes = [ "grouped" ]
|
||||
|
||||
connect() {
|
||||
this.group()
|
||||
}
|
||||
|
||||
group() {
|
||||
const notificationsByCardId = this.#groupNotificationsByCardId()
|
||||
|
||||
for (const cardId in notificationsByCardId) {
|
||||
const notifications = notificationsByCardId[cardId]
|
||||
if (notifications.length > 1) {
|
||||
this.#renderGroup(notifications)
|
||||
}
|
||||
}
|
||||
|
||||
this.grouped = true
|
||||
}
|
||||
|
||||
notificationTargetConnected(notification) {
|
||||
if (this.grouped && notification.parentElement !== this.hiddenNotificationsTarget) {
|
||||
this.#groupNotification(notification)
|
||||
}
|
||||
}
|
||||
|
||||
#groupNotificationsByCardId() {
|
||||
const notificationsByCardId = {}
|
||||
|
||||
this.notificationTargets.forEach(notification => {
|
||||
const cardId = notification.dataset.cardId
|
||||
notificationsByCardId[cardId] ||= []
|
||||
notificationsByCardId[cardId].push(notification)
|
||||
})
|
||||
|
||||
return notificationsByCardId
|
||||
}
|
||||
|
||||
#groupNotification(notification) {
|
||||
const groupedNotifications = this.#groupedNotificationsFor(notification)
|
||||
|
||||
if (groupedNotifications.length > 1) {
|
||||
this.#renderGroup(groupedNotifications)
|
||||
}
|
||||
}
|
||||
|
||||
#groupedNotificationsFor(notification) {
|
||||
const cardId = notification.dataset.cardId
|
||||
return this.notificationTargets
|
||||
.filter(notification => notification.dataset.cardId === cardId)
|
||||
}
|
||||
|
||||
#renderGroup(groupedNotifications) {
|
||||
groupedNotifications.sort((notification_1, notification_2) => parseInt(notification_1.dataset.timestamp) - parseInt(notification_2.dataset.timestamp))
|
||||
|
||||
this.#showAsGrouped(groupedNotifications[0], groupedNotifications.length)
|
||||
groupedNotifications.slice(1).forEach(notification => this.#hideInGroup(notification))
|
||||
}
|
||||
|
||||
#showAsGrouped(notification, size) {
|
||||
notification.classList.add(this.groupedClass)
|
||||
this.#showGroupedNotification(notification)
|
||||
this.#setGroupCount(notification, size)
|
||||
}
|
||||
|
||||
#hideInGroup(notification) {
|
||||
this.#hideGroupedNotification(notification)
|
||||
this.#setGroupCount(notification, "")
|
||||
}
|
||||
|
||||
// We use a hidden container instead of hiding the notifications directly so that the CSS that sort the
|
||||
// tray element indexes doesn't get messed up with the child positions changing.
|
||||
#showGroupedNotification(notification) {
|
||||
if (notification.parentElement === this.hiddenNotificationsTarget) {
|
||||
this.hiddenNotificationsTarget.parentElement.insertBefore(notification, this.hiddenNotificationsTarget)
|
||||
}
|
||||
}
|
||||
|
||||
#hideGroupedNotification(notification) {
|
||||
this.hiddenNotificationsTarget.appendChild(notification)
|
||||
}
|
||||
|
||||
#setGroupCount(notification, count) {
|
||||
notification.querySelector("[data-group-count]").textContent = count
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ class ImportMailer < ApplicationMailer
|
||||
mail to: identity.email_address, subject: "Your Fizzy account import is done"
|
||||
end
|
||||
|
||||
def failed(identity)
|
||||
mail to: identity.email_address, subject: "Your Fizzy account import failed"
|
||||
def failed(import)
|
||||
@import = import
|
||||
mail to: import.identity.email_address, subject: "Your Fizzy account import failed"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,10 +7,14 @@ class Notification::BundleMailer < ApplicationMailer
|
||||
@user = bundle.user
|
||||
@bundle = bundle
|
||||
@notifications = bundle.notifications
|
||||
.preload(:card, :creator, source: [ :board, :creator ])
|
||||
.reject { |n| n.source.nil? || n.card.nil? }
|
||||
@unsubscribe_token = @user.generate_token_for(:unsubscribe)
|
||||
|
||||
mail \
|
||||
to: bundle.user.identity.email_address,
|
||||
subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: New notifications"
|
||||
if @notifications.any?
|
||||
mail \
|
||||
to: bundle.user.identity.email_address,
|
||||
subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: New notifications"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -84,9 +84,16 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer
|
||||
gid = GlobalID.parse(node["gid"])
|
||||
|
||||
if gid
|
||||
record = gid.find
|
||||
node["sgid"] = record.attachable_sgid
|
||||
node.remove_attribute("gid")
|
||||
record = begin
|
||||
gid.find
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
nil
|
||||
end
|
||||
|
||||
if record&.account_id == account.id
|
||||
node["sgid"] = record.attachable_sgid
|
||||
node.remove_attribute("gid")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Account::DataTransfer::RecordSet
|
||||
class IntegrityError < StandardError; end
|
||||
class ConflictError < IntegrityError; end
|
||||
|
||||
IMPORT_BATCH_SIZE = 100
|
||||
|
||||
@@ -93,7 +94,7 @@ class Account::DataTransfer::RecordSet
|
||||
end
|
||||
|
||||
if model.exists?(id: data["id"])
|
||||
raise IntegrityError, "#{model} record with ID #{data['id']} already exists"
|
||||
raise ConflictError, "#{model} record with ID #{data['id']} already exists"
|
||||
end
|
||||
|
||||
check_associations_dont_exist(data)
|
||||
@@ -118,7 +119,7 @@ class Account::DataTransfer::RecordSet
|
||||
end
|
||||
|
||||
if associated_class.exists?(id: associated_id)
|
||||
raise IntegrityError, "#{model} record references existing #{association.name} (#{associated_class}) with ID #{associated_id}"
|
||||
raise ConflictError, "#{model} record references existing #{association.name} (#{associated_class}) with ID #{associated_id}"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ class Account::Import < ApplicationRecord
|
||||
has_one_attached :file
|
||||
|
||||
enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending
|
||||
enum :failure_reason, %w[ conflict invalid_export ].index_by(&:itself), prefix: :failed_due_to, scopes: false
|
||||
|
||||
scope :expired, -> { where(completed_at: ...24.hours.ago).or(where(status: :failed, created_at: ...7.days.ago)) }
|
||||
|
||||
@@ -26,6 +27,12 @@ class Account::Import < ApplicationRecord
|
||||
record_set.check(from: zip, start: last_id, callback: callback)
|
||||
end
|
||||
end
|
||||
rescue Account::DataTransfer::RecordSet::ConflictError => e
|
||||
mark_as_failed(:conflict)
|
||||
raise e
|
||||
rescue Account::DataTransfer::RecordSet::IntegrityError, ZipFile::InvalidFileError => e
|
||||
mark_as_failed(:invalid_export)
|
||||
raise e
|
||||
rescue => e
|
||||
mark_as_failed
|
||||
raise e
|
||||
@@ -44,6 +51,12 @@ class Account::Import < ApplicationRecord
|
||||
reconcile_account_storage
|
||||
|
||||
mark_completed
|
||||
rescue Account::DataTransfer::RecordSet::ConflictError => e
|
||||
mark_as_failed(:conflict)
|
||||
raise e
|
||||
rescue Account::DataTransfer::RecordSet::IntegrityError, ZipFile::InvalidFileError => e
|
||||
mark_as_failed(:invalid_export)
|
||||
raise e
|
||||
rescue => e
|
||||
mark_as_failed
|
||||
raise e
|
||||
@@ -60,9 +73,9 @@ class Account::Import < ApplicationRecord
|
||||
ImportMailer.completed(identity, account).deliver_later
|
||||
end
|
||||
|
||||
def mark_as_failed
|
||||
failed!
|
||||
ImportMailer.failed(identity).deliver_later
|
||||
def mark_as_failed(failure_reason = nil)
|
||||
update!(status: :failed, failure_reason: failure_reason)
|
||||
ImportMailer.failed(self).deliver_later
|
||||
end
|
||||
|
||||
def add_importer_to_all_access_boards
|
||||
|
||||
@@ -2,15 +2,11 @@ module Card::Readable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def read_by(user)
|
||||
notifications_for(user).tap do |notifications|
|
||||
notifications.each(&:read)
|
||||
end
|
||||
user.notifications.find_by(card: self)&.tap(&:read)
|
||||
end
|
||||
|
||||
def unread_by(user)
|
||||
all_notifications_for(user).tap do |notifications|
|
||||
notifications.each(&:unread)
|
||||
end
|
||||
user.notifications.find_by(card: self)&.tap(&:unread)
|
||||
end
|
||||
|
||||
def remove_inaccessible_notifications
|
||||
@@ -25,18 +21,6 @@ module Card::Readable
|
||||
Card::RemoveInaccessibleNotificationsJob.perform_later(self)
|
||||
end
|
||||
|
||||
def notifications_for(user)
|
||||
scope = user.notifications.unread
|
||||
scope.where(source: event_notification_sources)
|
||||
.or(scope.where(source: mention_notification_sources))
|
||||
end
|
||||
|
||||
def all_notifications_for(user)
|
||||
scope = user.notifications
|
||||
scope.where(source: event_notification_sources)
|
||||
.or(scope.where(source: mention_notification_sources))
|
||||
end
|
||||
|
||||
def event_notification_sources
|
||||
events.or(comment_creation_events)
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@ module PushNotifiable
|
||||
|
||||
included do
|
||||
after_create_commit :push_notification_later
|
||||
after_update_commit :push_notification_later, if: :source_id_previously_changed?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -14,7 +14,7 @@ class Event::Description
|
||||
end
|
||||
|
||||
def to_plain_text
|
||||
to_sentence(creator_name, quoted(card.title))
|
||||
to_sentence(creator_name, quoted(card.title)).html_safe
|
||||
end
|
||||
|
||||
private
|
||||
@@ -38,11 +38,11 @@ class Event::Description
|
||||
end
|
||||
|
||||
def creator_name
|
||||
h(event.creator.name)
|
||||
h event.creator.name
|
||||
end
|
||||
|
||||
def quoted(text)
|
||||
%("#{h text}")
|
||||
h %("#{text}")
|
||||
end
|
||||
|
||||
def card
|
||||
@@ -86,27 +86,43 @@ class Event::Description
|
||||
if event.assignees.include?(user)
|
||||
"#{creator} will handle #{card_title}"
|
||||
else
|
||||
"#{creator} assigned #{h event.assignees.pluck(:name).to_sentence} to #{card_title}"
|
||||
"#{creator} assigned #{assignee_names} to #{card_title}"
|
||||
end
|
||||
end
|
||||
|
||||
def unassigned_sentence(creator, card_title)
|
||||
assignees_text = event.assignees.include?(user) ? "yourself" : event.assignees.pluck(:name).to_sentence
|
||||
"#{creator} unassigned #{h(assignees_text)} from #{card_title}"
|
||||
"#{creator} unassigned #{unassigned_names} from #{card_title}"
|
||||
end
|
||||
|
||||
def renamed_sentence(creator, card_title)
|
||||
old_title = event.particulars.dig("particulars", "old_title")
|
||||
%(#{creator} renamed #{card_title} (was: "#{h old_title}"))
|
||||
%(#{creator} renamed #{card_title} (was: "#{old_title}"))
|
||||
end
|
||||
|
||||
def moved_sentence(creator, card_title)
|
||||
new_location = event.particulars.dig("particulars", "new_board") || event.particulars.dig("particulars", "new_collection")
|
||||
%(#{creator} moved #{card_title} to "#{h new_location}")
|
||||
%(#{creator} moved #{card_title} to "#{new_location}")
|
||||
end
|
||||
|
||||
def triaged_sentence(creator, card_title)
|
||||
column = event.particulars.dig("particulars", "column")
|
||||
%(#{creator} moved #{card_title} to "#{h column}")
|
||||
%(#{creator} moved #{card_title} to "#{column}")
|
||||
end
|
||||
|
||||
def assignee_names
|
||||
h event.assignees.pluck(:name).to_sentence
|
||||
end
|
||||
|
||||
def unassigned_names
|
||||
h(event.assignees.include?(user) ? "yourself" : assignee_names)
|
||||
end
|
||||
|
||||
def old_title
|
||||
h event.particulars.dig("particulars", "old_title")
|
||||
end
|
||||
|
||||
def new_location
|
||||
h(event.particulars.dig("particulars", "new_board") || event.particulars.dig("particulars", "new_collection"))
|
||||
end
|
||||
|
||||
def column
|
||||
h event.particulars.dig("particulars", "column")
|
||||
end
|
||||
end
|
||||
|
||||
+28
-17
@@ -5,32 +5,39 @@ class Notification < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :creator, class_name: "User"
|
||||
belongs_to :source, polymorphic: true
|
||||
belongs_to :card
|
||||
|
||||
scope :unread, -> { where(read_at: nil) }
|
||||
scope :read, -> { where.not(read_at: nil) }
|
||||
scope :ordered, -> { order(read_at: :desc, created_at: :desc) }
|
||||
scope :ordered, -> { order(read_at: :desc, updated_at: :desc) }
|
||||
scope :preloaded, -> { preload(:card, :creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) }
|
||||
|
||||
after_create_commit :broadcast_unread
|
||||
after_destroy_commit :broadcast_read
|
||||
before_validation :set_card
|
||||
after_create :bundle
|
||||
after_update :bundle, if: :source_id_previously_changed?
|
||||
|
||||
scope :preloaded, -> { preload(:creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) }
|
||||
after_create_commit -> { broadcast_prepend_later_to user, :notifications, target: "notifications" }
|
||||
after_update_commit -> { broadcast_update }
|
||||
after_destroy_commit -> { broadcast_remove_to user, :notifications }
|
||||
|
||||
delegate :notifiable_target, to: :source
|
||||
delegate :card, to: :source
|
||||
|
||||
def self.read_all
|
||||
all.each { |notification| notification.read }
|
||||
class << self
|
||||
def read_all
|
||||
all.each(&:read)
|
||||
end
|
||||
|
||||
def unread_all
|
||||
all.each(&:unread)
|
||||
end
|
||||
end
|
||||
|
||||
def read
|
||||
update!(read_at: Time.current)
|
||||
broadcast_read
|
||||
update!(read_at: Time.current, unread_count: 0)
|
||||
end
|
||||
|
||||
def unread
|
||||
update!(read_at: nil)
|
||||
broadcast_unread
|
||||
update!(read_at: nil, unread_count: 1)
|
||||
end
|
||||
|
||||
def read?
|
||||
@@ -38,15 +45,19 @@ class Notification < ApplicationRecord
|
||||
end
|
||||
|
||||
private
|
||||
def broadcast_unread
|
||||
broadcast_prepend_later_to user, :notifications, target: "notifications"
|
||||
end
|
||||
|
||||
def broadcast_read
|
||||
broadcast_remove_to user, :notifications
|
||||
def set_card
|
||||
self.card = source.card
|
||||
end
|
||||
|
||||
def bundle
|
||||
user.bundle(self) if user.settings.bundling_emails?
|
||||
end
|
||||
|
||||
def broadcast_update
|
||||
if read?
|
||||
broadcast_remove_to(user, :notifications)
|
||||
else
|
||||
broadcast_prepend_later_to(user, :notifications, target: "notifications")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,7 @@ class Notification::Bundle < ApplicationRecord
|
||||
enum :status, %i[ pending processing delivered ]
|
||||
|
||||
scope :due, -> { pending.where("ends_at <= ?", Time.current) }
|
||||
scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.created_at, notification.created_at) }
|
||||
scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.updated_at, notification.updated_at) }
|
||||
scope :overlapping_with, ->(other_bundle) do
|
||||
where(
|
||||
"(starts_at <= ? AND ends_at >= ?) OR (starts_at <= ? AND ends_at >= ?) OR (starts_at >= ? AND ends_at <= ?)",
|
||||
@@ -33,7 +33,7 @@ class Notification::Bundle < ApplicationRecord
|
||||
end
|
||||
|
||||
def notifications
|
||||
user.notifications.where(created_at: window).unread
|
||||
user.notifications.where(updated_at: window).unread
|
||||
end
|
||||
|
||||
def deliver
|
||||
|
||||
+17
-1
@@ -16,7 +16,23 @@ class Notifier
|
||||
if should_notify?
|
||||
# Processing recipients in order avoids deadlocks if notifications overlap.
|
||||
recipients.sort_by(&:id).map do |recipient|
|
||||
Notification.create! user: recipient, source: source, creator: creator
|
||||
notification = Notification.create_or_find_by(user: recipient, card: source.card) do |n|
|
||||
n.source = source
|
||||
n.creator = creator
|
||||
n.unread_count = 1
|
||||
end
|
||||
|
||||
unless notification.previously_new_record?
|
||||
# Always include source_type in the update to prevent a race condition between
|
||||
# concurrent Event and Mention notifier jobs: without this, Rails' dirty tracking
|
||||
# may skip source_type when it hasn't changed from the stale in-memory value,
|
||||
# even though another job has since modified it in the database, leaving
|
||||
# source_type and source_id mismatched.
|
||||
notification.source_type_will_change!
|
||||
notification.update!(source: source, creator: creator, read_at: nil, unread_count: notification.unread_count + 1)
|
||||
end
|
||||
|
||||
notification
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,12 +25,12 @@ module User::Notifiable
|
||||
|
||||
def expand_pending_bundle_for(notification)
|
||||
pending = notification_bundles.pending.last
|
||||
if pending.present? && notification.created_at < pending.starts_at
|
||||
pending.update!(starts_at: notification.created_at) # expand the window to include this notification
|
||||
if pending.present? && notification.updated_at < pending.starts_at
|
||||
pending.update!(starts_at: notification.updated_at) # expand the window to include this notification
|
||||
end
|
||||
end
|
||||
|
||||
def create_bundle_for(notification)
|
||||
notification_bundles.create!(starts_at: notification.created_at)
|
||||
notification_bundles.create!(starts_at: notification.updated_at)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,8 +22,8 @@ class Webhook::Delivery < ApplicationRecord
|
||||
|
||||
after_create_commit :deliver_later
|
||||
|
||||
def self.cleanup
|
||||
stale.delete_all
|
||||
def self.cleanup(batch_size: 500, pause: 0.1)
|
||||
sleep pause until stale.limit(batch_size).delete_all.zero?
|
||||
end
|
||||
|
||||
def deliver_later
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<% @page_title = "Import an account" %>
|
||||
|
||||
<div class="panel panel--centered flex flex-column gap">
|
||||
<div class="panel panel--centered flex flex-column gap" style="--panel-size: 54ch;">
|
||||
<header>
|
||||
<h1 class="txt-x-large font-weight-black txt-tight-lines margin-block-end-half">Import a Fizzy account</h1>
|
||||
<div>Create an account using data from a Fizzy export. Upload the exported .zip file below.</div>
|
||||
<h1 class="txt-x-large font-weight-black txt-tight-lines margin-block-end-none">Import a Fizzy account</h1>
|
||||
|
||||
<% if Fizzy.saas? %>
|
||||
<div class="font-weight-semibold margin-block-end">Running Fizzy on your own server and want us to host on <a class="txt-ink txt-underline" href="https://fizzy.do" target="_blank" rel="noopener noreferrer">fizzy.do</a> instead?</div>
|
||||
<div>Export your self-hosted account, then upload the .zip file below.</div>
|
||||
<% else %>
|
||||
<div class="font-weight-semibold margin-block-end">Ready to host Fizzy on your own server?</div>
|
||||
<div>Export your <a class="txt-ink txt-underline" href="https://fizzy.do" target="_blank" rel="noopener noreferrer">fizzy.do</a> account, then upload the .zip file below.</div>
|
||||
<% end %>
|
||||
</header>
|
||||
|
||||
<%= form_with url: account_imports_path, class: "flex flex-column gap", data: { controller: "form upload-preview" }, multipart: true do |form| %>
|
||||
@@ -17,6 +23,8 @@
|
||||
<span>Start Import →</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<p class="txt-small">If you run into issues or would like assistance, just <a href="mailto:support@fizzy.do" class="txt-ink txt-underline">send us an email</a>.</p>
|
||||
</div>
|
||||
|
||||
<% content_for :footer do %>
|
||||
|
||||
@@ -2,19 +2,31 @@
|
||||
|
||||
<%= turbo_stream_from @import %>
|
||||
|
||||
<div class="panel panel--centered flex flex-column gap-half">
|
||||
<div class="panel panel--centered flex flex-column gap-half" style="--panel-size: 54ch;">
|
||||
<h1 class="txt-x-large font-weight-black margin-block-end">Import status</h1>
|
||||
|
||||
<% case @import.status %>
|
||||
<% when "pending", "processing" %>
|
||||
<p class="txt-medium">Your import is in progress. This may take a while for large accounts.</p>
|
||||
<div class="import-status">
|
||||
Your import is in progress. This may take a while for large accounts.
|
||||
</div>
|
||||
<% when "completed" %>
|
||||
<p class="txt-medium txt-positive">Your import has completed successfully!</p>
|
||||
<%= link_to "Go to your account", landing_url(script_name: @import.account.slug), class: "btn btn--link center txt-medium" %>
|
||||
<div class="import-status import-status--success">
|
||||
<div><strong>Your import was successful!</strong></div>
|
||||
<%= link_to "Go to your account →", landing_url(script_name: @import.account.slug), class: "btn btn--link" %>
|
||||
</div>
|
||||
<% when "failed" %>
|
||||
<p class="txt-medium txt-negative">Your import failed.</p>
|
||||
<p class="txt-small">This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.</p>
|
||||
<%= link_to "Try again", new_account_import_path, class: "btn btn--plain center txt-medium" %>
|
||||
<div class="import-status import-status--error">
|
||||
<div><strong>Import failed</strong></div>
|
||||
<% if @import.failed_due_to_conflict? %>
|
||||
<div>The account you’re trying to import already exists. Make sure you’re importing a <%= Fizzy.saas? ? "self-hosted" : "fizzy.do" %> account.</div>
|
||||
<% elsif @import.failed_due_to_invalid_export? %>
|
||||
<div>The .zip file you uploaded doesn’t look like a Fizzy account export.</div>
|
||||
<% else %>
|
||||
<div>This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.</div>
|
||||
<% end %>
|
||||
<%= link_to "Try again", new_account_import_path, class: "btn" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,8 +39,10 @@
|
||||
<span>Get QR code</span>
|
||||
<% end %>
|
||||
|
||||
<dialog class="dialog panel shadow" data-dialog-target="dialog">
|
||||
<p class="margin-none-block-start"><strong>Scan this code with the camera on your mobile device</strong></p>
|
||||
<dialog class="dialog panel shadow" data-dialog-target="dialog" style="--panel-size: 50ch;">
|
||||
<p class="margin-none-block-start txt-balance">
|
||||
<strong>Scan this code to join <%= Current.account.name %>:</strong>
|
||||
</p>
|
||||
|
||||
<%= qr_code_image(url) %>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog" style="--panel-size: 48ch;">
|
||||
<h2 class="txt-large">Export all account data</h2>
|
||||
<p>This will generate a ZIP archive of all data in this account including all boards, cards, users, and settings.</p>
|
||||
<p>We‘ll email you a link to download the file when it‘s ready. The link will expire after 24 hours.</p>
|
||||
<p>We’ll email you a link to download the file when it’s ready. The link will expire after 24 hours.</p>
|
||||
|
||||
<div class="flex gap justify-center">
|
||||
<%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<section class="settings__section">
|
||||
<section class="settings__section settings__section--users">
|
||||
<header>
|
||||
<h2 class="divider">People on this account</h2>
|
||||
</header>
|
||||
|
||||
<%= tag.div class: "flex flex-column gap settings__user-filter", data: {
|
||||
<%= tag.div class: "settings__user-filter flex flex-column gap", data: {
|
||||
controller: "filter navigable-list",
|
||||
action: "keydown->navigable-list#navigate filter:changed->navigable-list#reset",
|
||||
navigable_list_focus_on_selection_value: true,
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<strong class="margin-block-end-half flex justify-center txt-small">Add an optional description to the public page</strong>
|
||||
<div class="border-radius input fill-white">
|
||||
<%= form_with model: board, class: "txt-align-start", data: { controller: "form", turbo_frame: "_top" } do |form| %>
|
||||
<%= form.rich_textarea :public_description, class: "rich-text-content txt-small",
|
||||
<%= form.rich_textarea :public_description, class: "lexxy-content txt-small",
|
||||
placeholder: "Add a public note about this board…",
|
||||
data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" },
|
||||
readonly: !Current.user.can_administer_board?(@board) %>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="panel panel--centered">
|
||||
<%= bridged_form_with model: @board, class: "flex flex-column gap", data: { controller: "form", action: "submit->form#preventEmptySubmit" } do |form| %>
|
||||
<h1 class="txt-x-large margin-none font-weight-black"><%= @page_title %></h1>
|
||||
<%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off", placeholder: "Name it…", data: { form_target: "input", action: "keydown.esc@document->form#cancel", validation_message: "Board names can‘t be blank" } %>
|
||||
<%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off", placeholder: "Name it…", data: { form_target: "input", action: "keydown.esc@document->form#cancel", validation_message: "Board names can’t be blank" } %>
|
||||
|
||||
<button type="submit" class="btn btn--link center" data-bridge--form-target="submit">
|
||||
<span>Create board</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ json.cache! card do
|
||||
json.description card.description.to_plain_text
|
||||
json.description_html card.description.to_s
|
||||
json.image_url card.image.presence && url_for(card.image)
|
||||
json.has_attachments card.has_attachments?
|
||||
|
||||
json.tags card.tags.pluck(:title).sort
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="comment__body rich-text-content" data-controller="syntax-highlight retarget-links" data-turbo-permanent>
|
||||
<div class="comment__body lexxy-content" data-controller="syntax-highlight retarget-links" data-turbo-permanent>
|
||||
<%= comment.body %>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</figure>
|
||||
|
||||
<div class="comment__content flex-inline flex-column full-width">
|
||||
<div class="comment__body rich-text-content" data-turbo-permanent>
|
||||
<div class="comment__body lexxy-content" data-turbo-permanent>
|
||||
<%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width",
|
||||
data: { controller: "form local-save",
|
||||
local_save_key_value: "comment-#{card.id}",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</figure>
|
||||
|
||||
<div class="comment__content flex flex-column flex-item-grow full-width">
|
||||
<div class="comment__body rich-text-content" data-turbo-permanent>
|
||||
<div class="comment__body lexxy-content" data-turbo-permanent>
|
||||
<%= form_with model: [ @card, @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:stop" } do |form| %>
|
||||
<%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) do %>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="flex gap-half">
|
||||
<%= link_to edit_card_path(card), class: "btn btn--circle-mobile borderless",
|
||||
data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Edit", bridge_icon_url: bridge_icon("pencil"), turbo_frame: dom_id(card, :edit) } do %>
|
||||
data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Edit", turbo_frame: dom_id(card, :edit) } do %>
|
||||
<%= icon_tag "pencil", class: "icon--mobile-only" %>
|
||||
<span>Edit</span>
|
||||
<kbd class="txt-x-small hide-on-touch">e</kbd>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<% end %>
|
||||
</h1>
|
||||
|
||||
<%= form.rich_textarea :description, class: "card__description rich-text-content",
|
||||
<%= form.rich_textarea :description, class: "card__description lexxy-content",
|
||||
placeholder: "Add some notes, context, pictures, or video about this…",
|
||||
data: { action: "lexxy:change->auto-save#change focusout->auto-save#submit" } do %>
|
||||
<%= general_prompts(card.board) %>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<h1 class="card__title flex align-start gap-half">
|
||||
<%= link_to card.title, edit_card_path(card), class: "card__title-link" %>
|
||||
<%= link_to card_html_title(card), edit_card_path(card), class: "card__title-link" %>
|
||||
</h1>
|
||||
|
||||
<% unless card.description.blank? %>
|
||||
<div class="card__description rich-text-content" data-controller="syntax-highlight retarget-links">
|
||||
<div class="card__description lexxy-content" data-controller="syntax-highlight retarget-links">
|
||||
<%= card.description %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<div class="card__body justify-space-between">
|
||||
<div class="card__content">
|
||||
<h3 class="card__title overflow-line-clamp">
|
||||
<%= card.title %>
|
||||
<%= card_html_title(card) %>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="card__body justify-space-between">
|
||||
<div class="card__content">
|
||||
<h1 class="card__title overflow-line-clamp">
|
||||
<%= card.title %>
|
||||
<%= card_html_title(card) %>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</div>
|
||||
|
||||
<% if card.tags.any? %>
|
||||
<div class="min-width overflow-ellipsis">
|
||||
<div class="card__tags-list min-width">
|
||||
<% card.tags.each_with_index do |tag, index| %>
|
||||
<%= link_to cards_path(board_ids: [ card.board ], tag_ids: [ tag.id ]),
|
||||
class: "card__tag btn btn--plain min-width txt-uppercase fill-transparent" do %>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<section id="<%= dom_id(card, :card_container) %>" class="card-perma" style="--card-color: <%= card.color %>;">
|
||||
<section id="<%= dom_id(card, :card_container) %>" class="card-perma card-perma--draft" style="--card-color: <%= card.color %>;">
|
||||
<% cache card do %>
|
||||
<div class="card-perma__actions card-perma__actions--left">
|
||||
<%= render "cards/container/image", card: card %>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<% end %>
|
||||
</h1>
|
||||
|
||||
<%= form.rich_textarea :description, class: "card__description rich-text-content",
|
||||
<%= form.rich_textarea :description, class: "card__description lexxy-content",
|
||||
placeholder: "Add some notes, context, pictures, or video about this…",
|
||||
data: { local_save_target: "input", action: "lexxy:change->local-save#save turbo:morph-element->local-save#restoreContent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do %>
|
||||
<%= general_prompts(@card.board) %>
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
<% @notifications.each do |notification| %>
|
||||
<%= turbo_stream.remove notification %>
|
||||
<% end %>
|
||||
<%= turbo_stream.remove @notification if @notification %>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<div class="header__actions header__actions--start">
|
||||
<%= link_back_to_board(@card.board) %>
|
||||
</div>
|
||||
<%= bridged_button_to_board(@card.board) %>
|
||||
<% end %>
|
||||
|
||||
<%= turbo_stream_from @card %>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<%= tag.details class: "expandable-on-native",
|
||||
open: true,
|
||||
data: {
|
||||
controller: "expandable-on-native",
|
||||
expandable_on_native_auto_expand_selector_value: "[data-filter-show=true]" } do %>
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
}
|
||||
|
||||
body {
|
||||
color: #17233C;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
img {
|
||||
@@ -72,13 +73,13 @@
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid #ccc;
|
||||
margin-top: 3em;
|
||||
margin-top: 32px;
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
.notification {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.notification__author {
|
||||
@@ -88,12 +89,25 @@
|
||||
}
|
||||
|
||||
.notification__board {
|
||||
font-size: 0.8em;
|
||||
font-weight: normal;
|
||||
margin-bottom: 0;
|
||||
font-size: 1.4em;
|
||||
font-weight: 900;
|
||||
margin-bottom: 16px;
|
||||
margin-top: 0;
|
||||
opacity: 0.66;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.notification__board a {
|
||||
color: #17233C;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.notification__board a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.notification__board-separator {
|
||||
border: none;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 2em 0 1em;
|
||||
}
|
||||
|
||||
.notification__details {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<p class="subtitle">Unfortunately, we couldn't import your Fizzy account.</p>
|
||||
|
||||
<p>
|
||||
This may be due to corrupted export data or a conflict with existing data.
|
||||
Please try again with a fresh export, or reach out for help if the problem persists.
|
||||
</p>
|
||||
<% if @import.failed_due_to_conflict? %>
|
||||
<p>It looks like the account you are trying to import already exists.</p>
|
||||
<% elsif @import.failed_due_to_invalid_export? %>
|
||||
<p>The ZIP file isn't a Fizzy account export.</p>
|
||||
<% else %>
|
||||
<p>This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export, or reach out for help if the problem persists.</p>
|
||||
<% end %>
|
||||
|
||||
<p class="footer">Need help? <%= mail_to "support@fizzy.do", "Send us an email" %>.</p>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
Unfortunately, we couldn't import your Fizzy account.
|
||||
|
||||
This may be due to corrupted export data or a conflict with existing data.
|
||||
Please try again with a fresh export, or reach out for help if the problem persists.
|
||||
<% if @import.failed_due_to_conflict? -%>
|
||||
It looks like the account you are trying to import already exists.
|
||||
<% elsif @import.failed_due_to_invalid_export? -%>
|
||||
The ZIP file isn't a Fizzy account export.
|
||||
<% else -%>
|
||||
This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export, or reach out for help if the problem persists.
|
||||
<% end -%>
|
||||
|
||||
Need help? Send us an email at support@fizzy.do
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %><%= " in #{ Current.account.name }" if @user.identity.accounts.many? %>.
|
||||
</p>
|
||||
|
||||
<% @notifications.group_by(&:card).each do |card, notifications| %>
|
||||
<h2 class="notification__board"><%= card.board.name %></h2>
|
||||
<%= link_to "##{ card.number } #{ card.title }", card, class: "card__title" %>
|
||||
<%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %>
|
||||
<% @notifications.group_by { |n| n.card.board }.sort_by { |board, _| board.name.downcase }.each do |board, board_notifications| %>
|
||||
<hr class="notification__board-separator">
|
||||
<h2 class="notification__board"><%= link_to board.name, board %></h2>
|
||||
<% board_notifications.group_by(&:card).each do |card, notifications| %>
|
||||
<%= link_to card, class: "card__title" do %>#<%= card.number %> <%= card_html_title(card) %><% end %>
|
||||
<%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<p class="footer">Fizzy emails you about new notifications every few hours. <br>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
Notifications since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %>
|
||||
You have <%= pluralize @notifications.count, "new notification" %><%= " in #{ Current.account.name }" if @user.identity.accounts.many? %>.
|
||||
|
||||
<% @notifications.group_by(&:card).each do |card, notifications| %>
|
||||
--------------------------------------------------------------------------------
|
||||
<% @notifications.group_by { |n| n.card.board }.sort_by { |board, _| board.name.downcase }.each do |board, board_notifications| %>
|
||||
<%= board.name %>
|
||||
<%= "-" * board.name.length %>
|
||||
|
||||
<%= card.board.name %>
|
||||
<% board_notifications.group_by(&:card).each do |card, notifications| %>
|
||||
<%= "##{ card.number } #{ card.title }" %>
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
<%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
|
||||
<%= notification_tag notification do %>
|
||||
<%= render "notifications/notification/header", notification: notification do %>
|
||||
<%= notification_toggle_read_button(notification, url: card_reading_path(notification.card)) %>
|
||||
<%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %>
|
||||
<% end %>
|
||||
<%= render "notifications/notification/body", notification: notification %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
json.cache! notification do
|
||||
json.(notification, :id)
|
||||
json.(notification, :id, :unread_count)
|
||||
json.read notification.read?
|
||||
json.read_at notification.read_at&.utc
|
||||
json.created_at notification.created_at.utc
|
||||
|
||||
@@ -8,9 +8,8 @@
|
||||
data-navigable-list-reverse-navigation-value="true"
|
||||
data-action="keydown->navigable-list#navigate dialog:show@document->navigable-list#reset turbo-visit->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside">
|
||||
<%= turbo_frame_tag "notifications", src: tray_notifications_path, refresh: "morph", data: {
|
||||
controller: "notifications-tray frame-reloader",
|
||||
notifications_tray_grouped_class: "notification--grouped",
|
||||
action: "turbo:frame-render->notifications-tray#group focus@window->frame-reloader#reload" } %>
|
||||
controller: "frame-reloader",
|
||||
action: "focus@window->frame-reloader#reload" } %>
|
||||
|
||||
<div class="tray__item tray__item--hat txt-x-small gap-half">
|
||||
<div data-navigable-list-target="item" class="full-width">
|
||||
|
||||
@@ -1 +1 @@
|
||||
json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification
|
||||
json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification, cached: true
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<%= notification_tag notification do %>
|
||||
<%= render "notifications/notification/header", notification: notification do %>
|
||||
<%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %>
|
||||
<% end %>
|
||||
<%= render "notifications/notification/body", notification: notification %>
|
||||
<% end %>
|
||||
@@ -3,7 +3,7 @@
|
||||
<h2 class="txt-medium margin-block-start-double margin-block-end-half txt-uppercase translucent">Previously seen</h2>
|
||||
|
||||
<div id="notifications_list_read" contents>
|
||||
<%= render partial: "notifications/index/notification", collection: page.records, cached: true %>
|
||||
<%= render partial: "notifications/notification", collection: page.records, cached: true %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
<% end %>
|
||||
|
||||
<div id="notifications_list" contents>
|
||||
<%= render partial: "notifications/index/notification", collection: unread, cached: true %>
|
||||
<%= render partial: "notifications/notification", collection: unread, cached: true %>
|
||||
</div>
|
||||
|
||||
<% if unread.any? %>
|
||||
<% total_unread_count = Current.user.notifications.unread.count %>
|
||||
<% if Current.user.notifications.unread.count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %>
|
||||
<% if total_unread_count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %>
|
||||
<div class="fill-highlight txt-x-small border-radius pad-block-half pad-inline">
|
||||
Showing the <%= NotificationsController::MAX_UNREAD_NOTIFICATIONS %> most recent (<%= total_unread_count - NotificationsController::MAX_UNREAD_NOTIFICATIONS %> are hidden)
|
||||
</div>
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
<div class="notifications-help" data-notifications-target="details">
|
||||
<% case
|
||||
when platform.firefox? && platform.android? %>
|
||||
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
|
||||
<h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>
|
||||
<ol>
|
||||
<li>Tap <em><%= icon_tag "lock", alt: "the View site information button" %></em> in the address bar.</li>
|
||||
<li>Tap <em>Notification</em> to change to <em>Allowed</em>.</li>
|
||||
</ol>
|
||||
<% when platform.edge? && platform.desktop? %>
|
||||
<h2>Turn on notifications for this website.</h2>
|
||||
<h3>Turn on notifications for this website.</h3>
|
||||
<ol>
|
||||
<li>Click <em><%= icon_tag "lock", alt: "the View site information button" %></em> left of the address bar.</li>
|
||||
<li>Under <em>Permissions for this site > Notifications</em>, choose <em>Allow</em>.</li>
|
||||
</ol>
|
||||
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
|
||||
<h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>
|
||||
<ol>
|
||||
<% if platform.windows? %>
|
||||
<li>Click <em>Start</em>, then <em>Settings</em>.</li>
|
||||
@@ -21,24 +21,24 @@
|
||||
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> <em>ON</em> for <%= platform.browser.capitalize %>.</li>
|
||||
<% else %>
|
||||
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
|
||||
<li>Click <em>System Settings…</em>.</li>
|
||||
<li>Click <em>System Settings…</em></li>
|
||||
<li>Click <em>Notifications</em>.</li>
|
||||
<li>Click <em><%= platform.browser.capitalize %></em>.</li>
|
||||
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> to <em>Allow notifications</em>.</li>
|
||||
<% end %>
|
||||
</ol>
|
||||
<% when platform.firefox? && platform.desktop? %>
|
||||
<h2>Turn on notifications for this website.</h2>
|
||||
<h3>Turn on notifications for this website.</h3>
|
||||
<ol>
|
||||
<li>Click <em><%= platform.browser.capitalize %></em> in the top left.</li>
|
||||
<li>Click <em>Settings…</em>.</li>
|
||||
<li>Click <em>Settings…</em></li>
|
||||
<li>Click <em>Privacy & Security</em> in the sidebar.</li>
|
||||
<li>Scroll down to <em>Permissions</em>.</li>
|
||||
<li>Click <em>Settings</em> next to <em>Notifications</em>.</li>
|
||||
<li>Select <em>Allow</em> next to <em><%= root_url %></em>.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
|
||||
<h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>
|
||||
<ol>
|
||||
<% if platform.windows? %>
|
||||
<li>Click <em>Start</em>, then <em>Settings</em>.</li>
|
||||
@@ -46,21 +46,21 @@
|
||||
<li>Click <em><%= icon_tag "switch", alt: "the toggle button" %></em> <em>ON</em> for <%= platform.browser.capitalize %>.</li>
|
||||
<% else %>
|
||||
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
|
||||
<li>Click <em>System Settings…</em>.</li>
|
||||
<li>Click <em>System Settings…</em></li>
|
||||
<li>Click <em>Notifications</em>.</li>
|
||||
<li>Click <em><%= platform.browser.capitalize %></em>.</li>
|
||||
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> to <em>Allow notifications</em>.</li>
|
||||
<% end %>
|
||||
</ol>
|
||||
<% when platform.chrome? && platform.desktop? %>
|
||||
<h2>Turn on notifications for this website.</h2>
|
||||
<h3>Turn on notifications for this website.</h3>
|
||||
<ol>
|
||||
<li>Click the <em><%= icon_tag "sliders", alt: "View site information" %></em> icon in the address bar.</li>
|
||||
<li>Click <em>Site Settings</em>.</li>
|
||||
<li>Ensure notifications are <em>Allowed</em>.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
|
||||
<h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>
|
||||
<ol>
|
||||
<% if platform.windows? %>
|
||||
<li>Click <em>Start</em>, then <em>Settings</em>.</li>
|
||||
@@ -68,14 +68,14 @@
|
||||
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> <em>ON</em> for <%= platform.browser.capitalize %>.</li>
|
||||
<% else %>
|
||||
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
|
||||
<li>Click <em>System Settings…</em>.</li>
|
||||
<li>Click <em>System Settings…</em></li>
|
||||
<li>Click <em>Notifications</em>.</li>
|
||||
<li>Click <em><%= platform.browser == "Chrome" ? "Google Chrome" : platform.browser.capitalize %></em>.</li>
|
||||
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> to <em>Allow notifications</em>.</li>
|
||||
<% end %>
|
||||
</ol>
|
||||
<% when platform.chrome? && platform.android? %>
|
||||
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
|
||||
<h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>
|
||||
<ol>
|
||||
<li>Tap the <em><%= icon_tag "menu-dots-vertical", alt: "More options" %></em> menu button.</li>
|
||||
<li>Tap <em>Settings</em>.</li>
|
||||
@@ -85,18 +85,18 @@
|
||||
<li>Tap <em><%= icon_tag "bell-alert", alt: "the notification bell" %></em> and select <em>Allow</em>.</li>
|
||||
</ol>
|
||||
<% when platform.safari? && platform.desktop? %>
|
||||
<h2>Turn on notifications for this website.</h2>
|
||||
<h3>Turn on notifications for this website.</h3>
|
||||
<ol>
|
||||
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
|
||||
<li>Click <em>System Settings…</em>.</li>
|
||||
<li>Click <em>System Settings…</em></li>
|
||||
<li>Click <em>Notifications</em>.</li>
|
||||
<li>Click <em><%= request.base_url %></em> in the list.</li>
|
||||
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> to <em>Allow notifications</em>.</li>
|
||||
</ol>
|
||||
<h2>Turn on notifications for <%= platform.browser.capitalize %>.</h2>
|
||||
<h3>Turn on notifications for <%= platform.browser.capitalize %>.</h3>
|
||||
<ol>
|
||||
<li>Click <em><%= platform.browser.capitalize %></em> in the top left.</li>
|
||||
<li>Click <em>Settings…</em>.</li>
|
||||
<li>Click <em>Settings…</em></li>
|
||||
<li>Click the <em>Websites</em> tab.</li>
|
||||
<li>Click <em>Notifications</em> in the sidebar.</li>
|
||||
<li>Click <em><%= request.base_url %></em> in the list.</li>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<% unless (platform.chrome? && !platform.ios?) || (platform.firefox? && !platform.android?) %>
|
||||
<div class="notifications-help pwa__instructions hide-in-pwa">
|
||||
<h2><%= platform.safari? && platform.desktop? ? "…or install" : "Install " -%> Fizzy as a web app.</h2>
|
||||
<h3><%= platform.safari? && platform.desktop? ? "…or install" : "Install " -%> Fizzy as a web app.</h3>
|
||||
<% case
|
||||
when platform.edge? %>
|
||||
<ol>
|
||||
@@ -20,7 +20,7 @@
|
||||
<% when platform.safari? && platform.desktop? %>
|
||||
<ol>
|
||||
<li>Click <em>File</em> in the top left.</li>
|
||||
<li>Click <em>Add to Dock…</em>.</li>
|
||||
<li>Click <em>Add to Dock…</em></li>
|
||||
</ol>
|
||||
<% when (platform.safari? || platform.chrome?) && platform.ios? %>
|
||||
<p>To receive push notifications in <%= platform.browser.capitalize %> for <%= platform.operating_system %>, you must first install Fizzy as a web app.</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="notifications-help hide-in-browser" data-notifications-target="details">
|
||||
<h2>Check your <%= platform.operating_system %> settings</h2>
|
||||
<h3>Check your <%= platform.operating_system %> settings</h3>
|
||||
<% case
|
||||
when platform.firefox? && platform.android? %>
|
||||
<ol>
|
||||
@@ -22,7 +22,7 @@
|
||||
<li>Click <em><%= icon_tag "switch", alt: "the toggle button" %></em> <em>ON</em> for Fizzy.</li>
|
||||
<% else %>
|
||||
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
|
||||
<li>Click <em>System Settings…</em>.</li>
|
||||
<li>Click <em>System Settings…</em></li>
|
||||
<li>Click <em>Notifications</em>.</li>
|
||||
<li>Click <em>Fizzy</em>.</li>
|
||||
<li>Click <em><%= icon_tag "switch", alt: "the allow notifications switch" %></em> to <em>Allow notifications</em>.</li>
|
||||
@@ -31,7 +31,7 @@
|
||||
<% when platform.safari? && platform.desktop? %>
|
||||
<ol>
|
||||
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
|
||||
<li>Click <em>System Settings…</em>.</li>
|
||||
<li>Click <em>System Settings…</em></li>
|
||||
<li>Click <em>Notifications</em>.</li>
|
||||
<li>Click <em>Fizzy</em>.</li>
|
||||
<li>Click <em><%= icon_tag "switch", alt: "the allow notifications switch" %></em> to <em>Allow notifications</em>.</li>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<%= turbo_frame_tag "notifications" do %>
|
||||
<%= render partial: "notifications/notification", collection: @notifications, cached: true %>
|
||||
<div hidden data-notifications-tray-target="hiddenNotifications"></div>
|
||||
<% end %>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<% end %>
|
||||
|
||||
<% if @board.public_description.present? %>
|
||||
<div class="card__board-public-description rich-text-content txt-align-center center margin-block-end" data-controller="syntax-highlight">
|
||||
<div class="card__board-public-description lexxy-content txt-align-center center margin-block-end" data-controller="syntax-highlight">
|
||||
<%= @board.public_description %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<div class="card__content">
|
||||
<h1 class="card__title flex align-start gap-half">
|
||||
<%= tag.span card.title, class: "card__title-link" %>
|
||||
<%= tag.span card_html_title(card), class: "card__title-link" %>
|
||||
</h1>
|
||||
<div class="card__description rich-text-content" data-controller="syntax-highlight">
|
||||
<div class="card__description lexxy-content" data-controller="syntax-highlight">
|
||||
<%= card.description %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<li class="popup__item txt-medium">
|
||||
<%= icon_tag "marker", class: "popup__icon" %>
|
||||
<%= link_to landing_path(script_name: account.slug), class: "btn overflow-ellipsis fill-transparent popup__btn" do %>
|
||||
<strong><%= account.name %></strong>
|
||||
<strong class="overflow-ellipsis"><%= account.name %></strong>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog" style="--panel-size: 48ch;">
|
||||
<h2 class="txt-large">Export your data</h2>
|
||||
<p>This will generate a ZIP archive of all cards you have access to.</p>
|
||||
<p>We‘ll email you a link to download the file when it‘s ready. The link will expire after 24 hours.</p>
|
||||
<p>We’ll email you a link to download the file when it’s ready. The link will expire after 24 hours.</p>
|
||||
|
||||
<div class="flex gap justify-center">
|
||||
<%= button_to "Start export", user_data_exports_path(@user), method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %>
|
||||
|
||||
@@ -15,13 +15,16 @@
|
||||
<span class="for-screen-reader">Display auto-login QR code</span>
|
||||
<% end %>
|
||||
|
||||
<dialog class="dialog panel shadow" data-dialog-target="dialog">
|
||||
<dialog class="dialog panel shadow" data-dialog-target="dialog" style="--panel-size: 50ch;">
|
||||
<p class="margin-none-block-start txt-balance">
|
||||
<strong>Scan this code to instantly log in on another device:</strong>
|
||||
</p>
|
||||
|
||||
<%= qr_code_image(url) %>
|
||||
|
||||
<form method="dialog" class="margin-block-start flex justify-center">
|
||||
<button class="btn" data-controller="tooltip">
|
||||
<%= icon_tag "remove" %>
|
||||
<span class="for-screen-reader">Close dialog</span>
|
||||
<button class="btn">
|
||||
<span>Done</span>
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
+54
-1
@@ -5,6 +5,7 @@
|
||||
# Usage:
|
||||
# bin/bundle-drift [check] # check for drift (default subcommand)
|
||||
# bin/bundle-drift correct # restore alignment (Gemfile.saas.lock is authoritative)
|
||||
# bin/bundle-drift forward # push Gemfile.lock changes into Gemfile.saas.lock
|
||||
require "bundler"
|
||||
require "fileutils"
|
||||
|
||||
@@ -118,11 +119,63 @@ class GemfileDriftCorrector
|
||||
end
|
||||
end
|
||||
|
||||
class GemfileDriftForwarder
|
||||
def forward
|
||||
drift = GemfileDriftChecker.new.check
|
||||
return puts "\nNothing to forward." if drift.empty?
|
||||
|
||||
puts "\nForwarding Gemfile.lock versions into Gemfile.saas.lock...\n\n"
|
||||
|
||||
original_content = File.read(GEMFILE_SAAS_LOCK)
|
||||
patched_content = original_content.dup
|
||||
|
||||
drift.each do |d|
|
||||
old_entry = "#{d[:name]} (#{d[:saas]})"
|
||||
new_entry = "#{d[:name]} (#{d[:oss]})"
|
||||
puts " #{old_entry} → #{new_entry}"
|
||||
patched_content.gsub!(old_entry, new_entry)
|
||||
end
|
||||
|
||||
File.write(GEMFILE_SAAS_LOCK, patched_content)
|
||||
|
||||
puts "\n▸ Verifying alignment"
|
||||
new_drift = GemfileDriftChecker.new.check
|
||||
|
||||
if new_drift.empty?
|
||||
puts "\n✓ Lock files are now in sync"
|
||||
show_diff(original_content, patched_content)
|
||||
else
|
||||
puts "\n✗ Lock files still have drift after forwarding."
|
||||
puts " Restoring original Gemfile.saas.lock."
|
||||
File.write(GEMFILE_SAAS_LOCK, original_content)
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def show_diff(original, patched)
|
||||
require "tempfile"
|
||||
|
||||
Tempfile.create("gemfile-saas-lock-original") do |f|
|
||||
f.write(original)
|
||||
f.flush
|
||||
|
||||
diff = `diff -u #{f.path} #{GEMFILE_SAAS_LOCK} 2>/dev/null`
|
||||
unless diff.empty?
|
||||
puts "\nChanges made to Gemfile.saas.lock:"
|
||||
puts diff
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
case command = ARGV[0] || "check"
|
||||
when "check"
|
||||
exit 1 unless GemfileDriftChecker.new.check.empty?
|
||||
when "correct"
|
||||
GemfileDriftCorrector.new.correct
|
||||
when "forward"
|
||||
GemfileDriftForwarder.new.forward
|
||||
else
|
||||
abort "Usage: bin/bundle-drift [check|correct]"
|
||||
abort "Usage: bin/bundle-drift [check|correct|forward]"
|
||||
end
|
||||
|
||||
@@ -115,7 +115,7 @@ step "Installing Ruby" mise install --yes
|
||||
eval "$(mise hook-env -s bash)"
|
||||
|
||||
if which pacman >/dev/null 2>&1; then
|
||||
packages=(imagemagick mariadb-libs openslide libvips gitleaks)
|
||||
packages=(imagemagick mariadb-libs openslide libvips libheif libwebp libjxl libraw poppler-glib libcgif ffmpeg rav1e svt-av1 gitleaks)
|
||||
if ! pacman -Q "${packages[@]}" >/dev/null 2>&1; then
|
||||
step "Installing packages" sudo pacman -S --noconfirm --needed "${packages[@]}"
|
||||
fi
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
{
|
||||
"warning_type": "Remote Code Execution",
|
||||
"warning_code": 24,
|
||||
"fingerprint": "b00571393e95a8c7d77b1ed963cfc5cc89333bc82f447cf8783549d817990305",
|
||||
"fingerprint": "f13f9f972c3f026ab4509a66ac284b8b7c1ba6191a3c4c89d2e9fb4584478f6d",
|
||||
"check_name": "UnsafeReflection",
|
||||
"message": "Unsafe reflection method `safe_constantize` called on model attribute",
|
||||
"file": "app/models/notifier.rb",
|
||||
@@ -139,5 +139,5 @@
|
||||
"note": "No user input allowed"
|
||||
}
|
||||
],
|
||||
"brakeman_version": "7.1.2"
|
||||
"brakeman_version": "8.0.1"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ pin_all_from "app/javascript/initializers", under: "initializers"
|
||||
pin_all_from "app/javascript/bridge/initializers", under: "bridge/initializers"
|
||||
pin_all_from "app/javascript/bridge/helpers", under: "bridge/helpers"
|
||||
pin_all_from "app/javascript/bridge/controllers/bridge", under: "controllers/bridge", to: "bridge/controllers/bridge"
|
||||
pin "marked" # @15.0.11
|
||||
pin "lexxy"
|
||||
pin "@rails/activestorage", to: "activestorage.esm.js"
|
||||
pin "@rails/actiontext", to: "actiontext.esm.js"
|
||||
|
||||
@@ -20,7 +20,7 @@ production: &production
|
||||
schedule: every hour at minute 12
|
||||
cleanup_webhook_deliveries:
|
||||
command: "Webhook::Delivery.cleanup"
|
||||
schedule: every 4 hours at minute 51
|
||||
schedule: every 15 minutes
|
||||
cleanup_magic_links:
|
||||
command: "MagicLink.cleanup"
|
||||
schedule: every 4 hours
|
||||
@@ -41,11 +41,11 @@ production: &production
|
||||
schedule: every 60 seconds
|
||||
<% end %>
|
||||
|
||||
beta:
|
||||
beta: &beta
|
||||
# Only Solid Queue maintenance
|
||||
clear_solid_queue_finished_jobs:
|
||||
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
||||
schedule: every hour at minute 12
|
||||
|
||||
staging: *production
|
||||
staging: *beta
|
||||
development: *production
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddCardIdToNotifications < ActiveRecord::Migration[8.2]
|
||||
def change
|
||||
add_column :notifications, :card_id, :uuid
|
||||
add_column :notifications, :unread_count, :integer, null: false, default: 0
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
class NotificationsDataMigration < ActiveRecord::Migration[8.2]
|
||||
BATCH_SIZE = 10_000
|
||||
|
||||
class Notification < ActiveRecord::Base
|
||||
self.table_name = "notifications"
|
||||
end
|
||||
|
||||
def change
|
||||
reversible do |dir|
|
||||
dir.up do
|
||||
populate_card_id
|
||||
collapse_duplicates
|
||||
end
|
||||
end
|
||||
|
||||
change_column_null :notifications, :card_id, false
|
||||
add_index :notifications, [ :user_id, :card_id ], unique: true
|
||||
end
|
||||
|
||||
private
|
||||
def populate_card_id
|
||||
execute(<<~SQL)
|
||||
UPDATE notifications
|
||||
SET card_id = (
|
||||
SELECT CASE events.eventable_type
|
||||
WHEN 'Card' THEN events.eventable_id
|
||||
WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = events.eventable_id)
|
||||
END
|
||||
FROM events
|
||||
WHERE events.id = notifications.source_id
|
||||
)
|
||||
WHERE notifications.card_id IS NULL
|
||||
AND notifications.source_type = 'Event'
|
||||
SQL
|
||||
|
||||
execute(<<~SQL)
|
||||
UPDATE notifications
|
||||
SET card_id = (
|
||||
SELECT CASE mentions.source_type
|
||||
WHEN 'Card' THEN mentions.source_id
|
||||
WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = mentions.source_id)
|
||||
END
|
||||
FROM mentions
|
||||
WHERE mentions.id = notifications.source_id
|
||||
)
|
||||
WHERE notifications.card_id IS NULL
|
||||
AND notifications.source_type = 'Mention'
|
||||
SQL
|
||||
end
|
||||
|
||||
def collapse_duplicates
|
||||
loop do
|
||||
duplicates = Notification.find_by_sql(<<~SQL)
|
||||
SELECT user_id, card_id,
|
||||
MAX(id) AS keep_id,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread_total
|
||||
FROM notifications
|
||||
WHERE card_id IS NOT NULL
|
||||
GROUP BY user_id, card_id
|
||||
HAVING COUNT(*) > 1
|
||||
LIMIT #{BATCH_SIZE}
|
||||
SQL
|
||||
|
||||
break if duplicates.empty?
|
||||
|
||||
duplicates.each do |row|
|
||||
Notification.where(user_id: row.user_id, card_id: row.card_id)
|
||||
.where.not(id: row.keep_id)
|
||||
.delete_all
|
||||
|
||||
Notification.where(id: row.keep_id)
|
||||
.update_all(unread_count: row.unread_total.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
# Set unread_count for remaining non-collapsed notifications
|
||||
execute(<<~SQL)
|
||||
UPDATE notifications
|
||||
SET unread_count = CASE WHEN read_at IS NULL THEN 1 ELSE 0 END
|
||||
WHERE unread_count = 0 AND card_id IS NOT NULL
|
||||
SQL
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddFailureReasonToAccountImports < ActiveRecord::Migration[8.2]
|
||||
def change
|
||||
add_column :account_imports, :failure_reason, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
class FixNotificationsOrderedIndex < ActiveRecord::Migration[8.2]
|
||||
def change
|
||||
add_index :notifications, [ :user_id, :read_at, :updated_at ],
|
||||
order: { read_at: :desc, updated_at: :desc },
|
||||
name: "index_notifications_on_user_id_and_read_at_and_updated_at"
|
||||
remove_index :notifications, name: "index_notifications_on_user_id_and_read_at_and_created_at"
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user