Mattias Bodlund 1 year ago
parent
commit
4cda1e5281
164 changed files with 8188 additions and 35 deletions
  1. +2
    -0
      .gitignore
  2. +4
    -0
      Gemfile
  3. +19
    -1
      Gemfile.lock
  4. BIN
      app/assets/fonts/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2
  5. +1
    -0
      app/assets/images/ico-h1.svg
  6. +1
    -0
      app/assets/images/ico-h2.svg
  7. +1
    -0
      app/assets/images/ico-h3.svg
  8. +1
    -0
      app/assets/images/ikea-foundation-white.svg
  9. +1
    -0
      app/assets/images/ikea-foundation.svg
  10. +274
    -0
      app/assets/stylesheets/admin.css
  11. +134
    -0
      app/assets/stylesheets/assets.css
  12. +142
    -0
      app/assets/stylesheets/attachments.css
  13. +158
    -0
      app/assets/stylesheets/form.css
  14. +1061
    -0
      app/assets/stylesheets/forms.css
  15. +436
    -0
      app/assets/stylesheets/lists.css
  16. +238
    -0
      app/assets/stylesheets/nodes.css
  17. +86
    -0
      app/assets/stylesheets/popup-menu.css
  18. +172
    -0
      app/assets/stylesheets/sessions.css
  19. +412
    -0
      app/assets/stylesheets/tom-select.css
  20. +410
    -0
      app/assets/stylesheets/trix.css
  21. +81
    -0
      app/controllers/admin/admin_controller.rb
  22. +82
    -0
      app/controllers/admin/assets_controller.rb
  23. +29
    -0
      app/controllers/admin/attachments_controller.rb
  24. +188
    -0
      app/controllers/admin/nodes_controller.rb
  25. +56
    -0
      app/controllers/admin/sessions_controller.rb
  26. +91
    -0
      app/controllers/admin/users_controller.rb
  27. +41
    -0
      app/helpers/admin/admin_helper.rb
  28. +2
    -0
      app/helpers/admin/assets_helper.rb
  29. +2
    -0
      app/helpers/admin/attachments_helper.rb
  30. +145
    -0
      app/helpers/admin/nodes_helper.rb
  31. +2
    -0
      app/helpers/admin/users_helper.rb
  32. +38
    -0
      app/javascript/admin.js
  33. +34
    -0
      app/javascript/controllers/assets_controller.js
  34. +15
    -0
      app/javascript/controllers/fields_controller.js
  35. +0
    -7
      app/javascript/controllers/hello_controller.js
  36. +26
    -0
      app/javascript/controllers/i18n_form_controller.js
  37. +27
    -0
      app/javascript/controllers/load_more_controller.js
  38. +14
    -0
      app/javascript/controllers/navbar_controller.js
  39. +59
    -0
      app/javascript/controllers/nodes_controller.js
  40. +82
    -0
      app/javascript/controllers/popup_controller.js
  41. +31
    -0
      app/javascript/controllers/select_controller.js
  42. +51
    -0
      app/javascript/controllers/sort_controller.js
  43. +117
    -0
      app/javascript/controllers/upload_controller.js
  44. +75
    -0
      app/javascript/controllers/utils_controller.js
  45. +8
    -1
      app/mailers/application_mailer.rb
  46. +12
    -0
      app/mailers/user_mailer.rb
  47. +19
    -0
      app/models/asset.rb
  48. +49
    -0
      app/models/attachment.rb
  49. +54
    -0
      app/models/concerns/ancestry_with_sorted_url.rb
  50. +37
    -0
      app/models/concerns/has_attachments.rb
  51. +48
    -0
      app/models/concerns/has_tags.rb
  52. +3
    -0
      app/models/current.rb
  53. +134
    -0
      app/models/node.rb
  54. +70
    -0
      app/models/user.rb
  55. +21
    -0
      app/models/verification_code.rb
  56. +33
    -0
      app/views/admin/assets/_asset.html.erb
  57. +14
    -0
      app/views/admin/assets/_form.html.erb
  58. +51
    -0
      app/views/admin/assets/_list.html.erb
  59. +12
    -0
      app/views/admin/assets/_sort.html.erb
  60. +3
    -0
      app/views/admin/assets/destroy.turbo_stream.erb
  61. +25
    -0
      app/views/admin/assets/edit.html.erb
  62. +57
    -0
      app/views/admin/assets/explore.turbo_stream.erb
  63. +9
    -0
      app/views/admin/assets/index.html.erb
  64. +30
    -0
      app/views/admin/assets/index.turbo_stream.erb
  65. +7
    -0
      app/views/admin/assets/upload.turbo_stream.erb
  66. +14
    -0
      app/views/admin/attachments/_asset.html.erb
  67. +43
    -0
      app/views/admin/attachments/_attachment.html.erb
  68. +14
    -0
      app/views/admin/attachments/new.turbo_stream.erb
  69. +5
    -0
      app/views/admin/nodes/_drawer.html.erb
  70. +14
    -0
      app/views/admin/nodes/_edit.html.erb
  71. +203
    -0
      app/views/admin/nodes/_form.html.erb
  72. +17
    -0
      app/views/admin/nodes/_node.html.erb
  73. +24
    -0
      app/views/admin/nodes/_tree.html.erb
  74. +38
    -0
      app/views/admin/nodes/_tree_node.html.erb
  75. +8
    -0
      app/views/admin/nodes/children.turbo_stream.erb
  76. +27
    -0
      app/views/admin/nodes/create.turbo_stream.erb
  77. +16
    -0
      app/views/admin/nodes/destroy.turbo_stream.erb
  78. +13
    -0
      app/views/admin/nodes/edit.html.erb
  79. +5
    -0
      app/views/admin/nodes/edit.turbo_stream.erb
  80. +15
    -0
      app/views/admin/nodes/index.html.erb
  81. +15
    -0
      app/views/admin/nodes/sort.turbo_stream.erb
  82. +3
    -0
      app/views/admin/nodes/tree.turbo_stream.erb
  83. +10
    -0
      app/views/admin/nodes/update.turbo_stream.erb
  84. +29
    -0
      app/views/admin/sessions/new.html.erb
  85. +20
    -0
      app/views/admin/sessions/verification.html.erb
  86. +27
    -0
      app/views/admin/users/_form.html.erb
  87. +29
    -0
      app/views/admin/users/_user.html.erb
  88. +19
    -0
      app/views/admin/users/edit.html.erb
  89. +41
    -0
      app/views/admin/users/index.html.erb
  90. +17
    -0
      app/views/admin/users/new.html.erb
  91. +11
    -0
      app/views/kaminari/_first_page.html.erb
  92. +8
    -0
      app/views/kaminari/_gap.html.erb
  93. +11
    -0
      app/views/kaminari/_last_page.html.erb
  94. +11
    -0
      app/views/kaminari/_next_page.html.erb
  95. +12
    -0
      app/views/kaminari/_page.html.erb
  96. +18
    -0
      app/views/kaminari/_paginator.html.erb
  97. +11
    -0
      app/views/kaminari/_prev_page.html.erb
  98. +5
    -0
      app/views/layouts/_flash.html.erb
  99. +70
    -0
      app/views/layouts/admin.html.erb
  100. +120
    -12
      app/views/layouts/mailer.html.erb

+ 2
- 0
.gitignore View File

@ -31,6 +31,8 @@
/public/assets
/dkim
# Ignore master key for decrypting credentials and more.
/config/master.key
/config/credentials.yml.enc


+ 4
- 0
Gemfile View File

@ -32,6 +32,10 @@ gem 'kaminari'
gem 'ancestry'
gem 'acts_as_list'
gem 'date_validator'
gem 'premailer-rails'
gem 'dkim'
# Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis]
# gem "kredis"


+ 19
- 1
Gemfile.lock View File

@ -100,10 +100,16 @@ GEM
concurrent-ruby (1.2.3)
connection_pool (2.4.1)
crass (1.0.6)
css_parser (1.17.1)
addressable
date (3.3.4)
date_validator (0.12.0)
activemodel (>= 3)
activesupport (>= 3)
debug (1.9.2)
irb (~> 1.10)
reline (>= 0.3.8)
dkim (1.1.0)
dotenv (3.1.0)
dotenv-rails (3.1.0)
dotenv (= 3.1.0)
@ -113,6 +119,7 @@ GEM
ffi (1.16.3)
globalid (1.2.1)
activesupport (>= 6.1)
htmlentities (4.3.4)
i18n (1.14.4)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
@ -185,6 +192,14 @@ GEM
pg_search (2.3.6)
activerecord (>= 5.2)
activesupport (>= 5.2)
premailer (1.23.0)
addressable
css_parser (>= 1.12.0)
htmlentities (>= 4.0.0)
premailer-rails (1.12.0)
actionmailer (>= 3)
net-smtp
premailer (~> 1.7, >= 1.7.9)
propshaft (0.8.0)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
@ -241,7 +256,7 @@ GEM
redis-client (0.22.1)
connection_pool
regexp_parser (2.9.0)
reline (0.5.2)
reline (0.5.3)
io-console (~> 0.5)
request_store (1.6.0)
rack (>= 1.4)
@ -298,7 +313,9 @@ DEPENDENCIES
bcrypt (~> 3.1.7)
bootsnap
capybara
date_validator
debug
dkim
dotenv-rails
image_processing (~> 1.2)
importmap-rails
@ -307,6 +324,7 @@ DEPENDENCIES
mobility (~> 1.3.0.rc1)
pg (~> 1.1)
pg_search
premailer-rails
propshaft
puma (>= 5.0)
rails (~> 7.1.3, >= 7.1.3.2)


BIN
app/assets/fonts/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2 View File


+ 1
- 0
app/assets/images/ico-h1.svg View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.68,19.46H9.83V13H5.61v6.47H2.75V4.54H5.61v6.12H9.83V4.54h2.85Zm8.57,0H18.37V8L14.6,9.36V6.88l6.5-2.34h.15Z"/></svg>

+ 1
- 0
app/assets/images/ico-h2.svg View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11.29,19.57H8.44V13.1H4.22v6.47H1.36V4.64H4.22v6.12H8.44V4.64h2.85Zm11.35,0H12.46V17.62l4.83-5.19c.38-.41.7-.78,1-1.12a8.46,8.46,0,0,0,.63-.91,4.3,4.3,0,0,0,.35-.78,2.47,2.47,0,0,0,.1-.7A2.72,2.72,0,0,0,19.19,8a1.85,1.85,0,0,0-.41-.66A1.64,1.64,0,0,0,18.16,7a2.09,2.09,0,0,0-.81-.15,2.2,2.2,0,0,0-1.75.66A2.66,2.66,0,0,0,15,9.3H12.15a4.78,4.78,0,0,1,.38-1.88,4.66,4.66,0,0,1,1.08-1.55,4.75,4.75,0,0,1,1.67-1,5.78,5.78,0,0,1,2.17-.39,5.93,5.93,0,0,1,2,.32,4.08,4.08,0,0,1,1.47.89A3.86,3.86,0,0,1,21.82,7a4.86,4.86,0,0,1,.31,1.78,4.17,4.17,0,0,1-.23,1.41,5.68,5.68,0,0,1-.66,1.33,11.34,11.34,0,0,1-1,1.34c-.39.45-.83.93-1.32,1.43l-2.81,3h6.55Z"/></svg>

+ 1
- 0
app/assets/images/ico-h3.svg View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11.28,19.46H8.43V13H4.2v6.47H1.34V4.54H4.2v6.12H8.43V4.54h2.85Zm4.46-8.71h1.55A2.31,2.31,0,0,0,19,10.16a2.12,2.12,0,0,0,.56-1.54,2.55,2.55,0,0,0-.12-.81,1.69,1.69,0,0,0-1-1,2.82,2.82,0,0,0-.92-.14,2.58,2.58,0,0,0-.8.12,2.08,2.08,0,0,0-.66.35,1.59,1.59,0,0,0-.44.56,1.52,1.52,0,0,0-.17.74H12.58A3.65,3.65,0,0,1,13,6.75a4,4,0,0,1,1-1.29,4.9,4.9,0,0,1,1.55-.83,5.93,5.93,0,0,1,1.91-.3,7.22,7.22,0,0,1,2,.27,4.38,4.38,0,0,1,1.58.81,3.66,3.66,0,0,1,1,1.34,4.41,4.41,0,0,1,.36,1.84,3,3,0,0,1-.14.94,3.63,3.63,0,0,1-.42.89,3.58,3.58,0,0,1-.69.78,4.62,4.62,0,0,1-.94.61,3.94,3.94,0,0,1,1.08.57,3.34,3.34,0,0,1,.76.79,3.77,3.77,0,0,1,.44,1,4.6,4.6,0,0,1,.14,1.13,4.18,4.18,0,0,1-.4,1.87,4.1,4.1,0,0,1-1.1,1.38,5,5,0,0,1-1.65.85,7.24,7.24,0,0,1-2.07.29,6.79,6.79,0,0,1-1.86-.26A5.2,5.2,0,0,1,14,18.62a3.93,3.93,0,0,1-1.13-1.33,4,4,0,0,1-.42-1.89h2.85a1.91,1.91,0,0,0,.16.8,1.79,1.79,0,0,0,.47.62,2,2,0,0,0,.71.41,2.57,2.57,0,0,0,.9.15,2.39,2.39,0,0,0,1.7-.57,2,2,0,0,0,.62-1.56,2.94,2.94,0,0,0-.17-1,1.82,1.82,0,0,0-.51-.7,2.09,2.09,0,0,0-.8-.41A3.76,3.76,0,0,0,17.29,13H15.74Z"/></svg>

+ 1
- 0
app/assets/images/ikea-foundation-white.svg View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.8 24"><path d="M59.03,0H0v24h59.03V0Z" style="fill:#0058a3; stroke-width:0px;"/><path d="M1.15,12c0,5.81,11.89,10.62,28.36,10.62s28.36-4.81,28.36-10.62S45.99,1.38,29.52,1.38,1.15,6.2,1.15,12h0Z" style="fill:#ffdb00; stroke-width:0px;"/><path d="M27.84,15.41c.17.26.38.5.62.75h-6.38c0-.25-.24-.77-.52-1.18-.27-.41-1.74-2.63-1.74-2.63v3.06c0,.25,0,.5.13.75h-5.32c.13-.25.13-.5.13-.75v-7.27c0-.25,0-.5-.13-.75h5.32c-.13.25-.13.5-.13.75v3.17s1.7-2.22,2.09-2.73c.3-.4.66-.94.66-1.19h5.55c-.38.25-.81.71-1.15,1.13-.31.37-2.03,2.48-2.03,2.48,0,0,2.56,3.9,2.9,4.4h0ZM29.51,8.14v7.27c0,.25,0,.5-.13.75h10.27v-2.43c-.25.13-.5.13-.75.13h-4.32v-1.15h4.15v-1.85h-4.15v-1.15h4.32c.25,0,.5,0,.75.12v-2.43h-10.27c.13.25.13.5.13.75,0,0,0,0,0,0ZM53.89,15.41c.1.25.19.5.41.75h-5.56c.03-.25-.07-.5-.17-.75,0,0-.08-.2-.2-.5l-.05-.13h-3.21l-.05.13-.18.5c-.09.25-.18.5-.15.75h-4.4c.22-.25.31-.5.4-.75.15-.4,2.43-6.7,2.63-7.27.09-.25.18-.5.15-.75h7.42c-.06.25.07.5.17.75.22.56,2.59,6.77,2.78,7.26,0,0,0,0,0,0ZM47.61,12.92c-.4-1.05-.74-1.94-.78-2.03-.07-.19-.13-.38-.17-.58,0,0-.06.32-.15.58-.03.08-.35.97-.73,2.03,0,0,1.83,0,1.83,0ZM12.12,7.38h-5.78c.13.25.13.5.13.75v7.27c0,.25,0,.5-.13.75h5.78c-.13-.25-.13-.5-.13-.75v-7.27c0-.25,0-.5.13-.75h0ZM52.11,8.08c0-.67.48-1.15,1.15-1.15s1.15.49,1.15,1.15-.48,1.15-1.15,1.15-1.15-.48-1.15-1.15ZM52.34,8.08c0,.51.39.92.92.92.5,0,.91-.39.92-.89,0-.01,0-.02,0-.03,0-.5-.39-.91-.89-.92-.01,0-.02,0-.03,0-.54,0-.92.39-.92.92ZM53.04,8.77h-.21v-1.38h.52c.24,0,.43.21.43.45,0,.17-.1.33-.24.41l.3.53h-.23l-.27-.49h-.3v.49h0ZM53.04,8.08h.28c.14,0,.26-.1.26-.24s-.12-.24-.26-.24h-.28v.49Z" style="fill:#0058a3; stroke-width:0px;"/><path d="M118.12,19.2V4.8h8.04v1.59h-6.23v5.1h5.84v1.59h-5.84v6.11h-1.82ZM132.52,19.4c-2.86,0-4.96-2.04-4.96-5.63s1.91-5.59,5.02-5.59c2.92,0,4.98,2.02,4.98,5.59s-1.96,5.63-5.04,5.63h0ZM132.57,17.93c2.18,0,3.16-1.59,3.16-4.15s-.99-4.09-3.19-4.09-3.14,1.53-3.14,4.09.97,4.15,3.16,4.15M148.89,19.2h-1.45l-.26-1.43h-.08c-.71,1.13-2.04,1.63-3.43,1.63-2.6,0-3.93-1.19-3.93-3.93v-7.08h1.79v6.96c0,1.74.77,2.58,2.4,2.58,2.4,0,3.18-1.39,3.18-3.91v-5.63h1.77v10.81h0,0ZM161,12.16v7.04h-1.75v-6.92c0-1.73-.77-2.6-2.42-2.6-2.4,0-3.16,1.39-3.16,3.91v5.61h-1.77v-10.81h1.43l.26,1.47h.1c.71-1.13,2.04-1.67,3.41-1.67,2.58,0,3.91,1.19,3.91,3.98M163.57,13.82c0-3.71,1.77-5.62,4.46-5.62,1.67,0,2.68.7,3.35,1.59h.12c-.04-.34-.12-1.19-.12-1.59V3.87h1.77v15.33h-1.43l-.26-1.45h-.08c-.64.93-1.67,1.66-3.37,1.66-2.68,0-4.43-1.88-4.43-5.59h0,0ZM171.39,14.16v-.32c0-2.68-.72-4.18-3.13-4.18-1.91,0-2.86,1.63-2.86,4.2s.95,4.07,2.88,4.07c2.28,0,3.1-1.25,3.1-3.77M184.36,11.84v7.36h-1.29l-.34-1.53h-.08c-.95,1.19-1.82,1.74-3.63,1.74-1.96,0-3.41-1.01-3.41-3.21s1.65-3.35,5.18-3.45l1.83-.06v-.64c0-1.8-.83-2.4-2.24-2.4-1.13,0-2.16.4-3.05.83l-.54-1.33c.95-.5,2.28-.93,3.69-.93,2.62,0,3.87,1.11,3.87,3.63,0,0,0,0,0,0ZM181.01,13.98c-2.7.1-3.57.87-3.57,2.24,0,1.21.8,1.75,1.97,1.75,1.81,0,3.18-.99,3.18-3.09v-.97l-1.59.06h0ZM192.74,17.73v1.35c-.39.18-1.17.32-1.82.32-1.69,0-3.16-.72-3.16-3.35v-6.29h-1.53v-.85l1.55-.7.71-2.3h1.05v2.48h3.13v1.37h-3.13v6.25c0,1.31.71,1.94,1.69,1.94.52,0,1.17-.1,1.51-.22h0ZM196.82,5.47c0,.77-.48,1.13-1.03,1.13-.58,0-1.05-.36-1.05-1.13s.46-1.13,1.05-1.13c.54,0,1.03.34,1.03,1.13h0ZM196.67,19.2h-1.77v-10.81h1.77v10.81ZM204.11,19.4c-2.86,0-4.96-2.04-4.96-5.63s1.91-5.59,5.02-5.59c2.92,0,4.98,2.02,4.98,5.59s-1.96,5.63-5.04,5.63h0ZM204.15,17.93c2.18,0,3.16-1.59,3.16-4.15s-.99-4.09-3.19-4.09-3.14,1.53-3.14,4.09.97,4.15,3.16,4.15M220.8,12.16v7.04h-1.75v-6.92c0-1.73-.77-2.6-2.42-2.6-2.4,0-3.16,1.39-3.16,3.91v5.61h-1.77v-10.81h1.43l.26,1.47h.1c.71-1.13,2.04-1.67,3.41-1.67,2.58,0,3.91,1.19,3.91,3.98M72.83,4.8h-1.81v14.4h1.81V4.8ZM85.14,19.2l-5.1-6.88-1.47,1.29v5.59h-1.81V4.8h1.81v7.1c.81-.91,1.65-1.82,2.48-2.74l3.89-4.36h2.11l-5.7,6.27,5.93,8.13h-2.14ZM89.24,19.2V4.8h8.04v1.59h-6.23v4.5h5.86v1.58h-5.86v5.14h6.23v1.59h-8.04ZM107.74,14.74h-5.7l-1.71,4.46h-1.83l5.62-14.46h1.63l5.6,14.46h-1.88s-1.73-4.46-1.73-4.46ZM105.58,8.77c-.25-.71-.47-1.43-.69-2.16-.18.73-.39,1.45-.62,2.16l-1.63,4.36h4.55s-1.61-4.36-1.61-4.36Z" style="fill:#FFF;"/></svg>

+ 1
- 0
app/assets/images/ikea-foundation.svg View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.8 24"><path d="M59.03,0H0v24h59.03V0Z" style="fill:#0058a3; stroke-width:0px;"/><path d="M1.15,12c0,5.81,11.89,10.62,28.36,10.62s28.36-4.81,28.36-10.62S45.99,1.38,29.52,1.38,1.15,6.2,1.15,12h0Z" style="fill:#ffdb00; stroke-width:0px;"/><path d="M27.84,15.41c.17.26.38.5.62.75h-6.38c0-.25-.24-.77-.52-1.18-.27-.41-1.74-2.63-1.74-2.63v3.06c0,.25,0,.5.13.75h-5.32c.13-.25.13-.5.13-.75v-7.27c0-.25,0-.5-.13-.75h5.32c-.13.25-.13.5-.13.75v3.17s1.7-2.22,2.09-2.73c.3-.4.66-.94.66-1.19h5.55c-.38.25-.81.71-1.15,1.13-.31.37-2.03,2.48-2.03,2.48,0,0,2.56,3.9,2.9,4.4h0ZM29.51,8.14v7.27c0,.25,0,.5-.13.75h10.27v-2.43c-.25.13-.5.13-.75.13h-4.32v-1.15h4.15v-1.85h-4.15v-1.15h4.32c.25,0,.5,0,.75.12v-2.43h-10.27c.13.25.13.5.13.75,0,0,0,0,0,0ZM53.89,15.41c.1.25.19.5.41.75h-5.56c.03-.25-.07-.5-.17-.75,0,0-.08-.2-.2-.5l-.05-.13h-3.21l-.05.13-.18.5c-.09.25-.18.5-.15.75h-4.4c.22-.25.31-.5.4-.75.15-.4,2.43-6.7,2.63-7.27.09-.25.18-.5.15-.75h7.42c-.06.25.07.5.17.75.22.56,2.59,6.77,2.78,7.26,0,0,0,0,0,0ZM47.61,12.92c-.4-1.05-.74-1.94-.78-2.03-.07-.19-.13-.38-.17-.58,0,0-.06.32-.15.58-.03.08-.35.97-.73,2.03,0,0,1.83,0,1.83,0ZM12.12,7.38h-5.78c.13.25.13.5.13.75v7.27c0,.25,0,.5-.13.75h5.78c-.13-.25-.13-.5-.13-.75v-7.27c0-.25,0-.5.13-.75h0ZM52.11,8.08c0-.67.48-1.15,1.15-1.15s1.15.49,1.15,1.15-.48,1.15-1.15,1.15-1.15-.48-1.15-1.15ZM52.34,8.08c0,.51.39.92.92.92.5,0,.91-.39.92-.89,0-.01,0-.02,0-.03,0-.5-.39-.91-.89-.92-.01,0-.02,0-.03,0-.54,0-.92.39-.92.92ZM53.04,8.77h-.21v-1.38h.52c.24,0,.43.21.43.45,0,.17-.1.33-.24.41l.3.53h-.23l-.27-.49h-.3v.49h0ZM53.04,8.08h.28c.14,0,.26-.1.26-.24s-.12-.24-.26-.24h-.28v.49Z" style="fill:#0058a3; stroke-width:0px;"/><path d="M118.12,19.2V4.8h8.04v1.59h-6.23v5.1h5.84v1.59h-5.84v6.11h-1.82ZM132.52,19.4c-2.86,0-4.96-2.04-4.96-5.63s1.91-5.59,5.02-5.59c2.92,0,4.98,2.02,4.98,5.59s-1.96,5.63-5.04,5.63h0ZM132.57,17.93c2.18,0,3.16-1.59,3.16-4.15s-.99-4.09-3.19-4.09-3.14,1.53-3.14,4.09.97,4.15,3.16,4.15M148.89,19.2h-1.45l-.26-1.43h-.08c-.71,1.13-2.04,1.63-3.43,1.63-2.6,0-3.93-1.19-3.93-3.93v-7.08h1.79v6.96c0,1.74.77,2.58,2.4,2.58,2.4,0,3.18-1.39,3.18-3.91v-5.63h1.77v10.81h0,0ZM161,12.16v7.04h-1.75v-6.92c0-1.73-.77-2.6-2.42-2.6-2.4,0-3.16,1.39-3.16,3.91v5.61h-1.77v-10.81h1.43l.26,1.47h.1c.71-1.13,2.04-1.67,3.41-1.67,2.58,0,3.91,1.19,3.91,3.98M163.57,13.82c0-3.71,1.77-5.62,4.46-5.62,1.67,0,2.68.7,3.35,1.59h.12c-.04-.34-.12-1.19-.12-1.59V3.87h1.77v15.33h-1.43l-.26-1.45h-.08c-.64.93-1.67,1.66-3.37,1.66-2.68,0-4.43-1.88-4.43-5.59h0,0ZM171.39,14.16v-.32c0-2.68-.72-4.18-3.13-4.18-1.91,0-2.86,1.63-2.86,4.2s.95,4.07,2.88,4.07c2.28,0,3.1-1.25,3.1-3.77M184.36,11.84v7.36h-1.29l-.34-1.53h-.08c-.95,1.19-1.82,1.74-3.63,1.74-1.96,0-3.41-1.01-3.41-3.21s1.65-3.35,5.18-3.45l1.83-.06v-.64c0-1.8-.83-2.4-2.24-2.4-1.13,0-2.16.4-3.05.83l-.54-1.33c.95-.5,2.28-.93,3.69-.93,2.62,0,3.87,1.11,3.87,3.63,0,0,0,0,0,0ZM181.01,13.98c-2.7.1-3.57.87-3.57,2.24,0,1.21.8,1.75,1.97,1.75,1.81,0,3.18-.99,3.18-3.09v-.97l-1.59.06h0ZM192.74,17.73v1.35c-.39.18-1.17.32-1.82.32-1.69,0-3.16-.72-3.16-3.35v-6.29h-1.53v-.85l1.55-.7.71-2.3h1.05v2.48h3.13v1.37h-3.13v6.25c0,1.31.71,1.94,1.69,1.94.52,0,1.17-.1,1.51-.22h0ZM196.82,5.47c0,.77-.48,1.13-1.03,1.13-.58,0-1.05-.36-1.05-1.13s.46-1.13,1.05-1.13c.54,0,1.03.34,1.03,1.13h0ZM196.67,19.2h-1.77v-10.81h1.77v10.81ZM204.11,19.4c-2.86,0-4.96-2.04-4.96-5.63s1.91-5.59,5.02-5.59c2.92,0,4.98,2.02,4.98,5.59s-1.96,5.63-5.04,5.63h0ZM204.15,17.93c2.18,0,3.16-1.59,3.16-4.15s-.99-4.09-3.19-4.09-3.14,1.53-3.14,4.09.97,4.15,3.16,4.15M220.8,12.16v7.04h-1.75v-6.92c0-1.73-.77-2.6-2.42-2.6-2.4,0-3.16,1.39-3.16,3.91v5.61h-1.77v-10.81h1.43l.26,1.47h.1c.71-1.13,2.04-1.67,3.41-1.67,2.58,0,3.91,1.19,3.91,3.98M72.83,4.8h-1.81v14.4h1.81V4.8ZM85.14,19.2l-5.1-6.88-1.47,1.29v5.59h-1.81V4.8h1.81v7.1c.81-.91,1.65-1.82,2.48-2.74l3.89-4.36h2.11l-5.7,6.27,5.93,8.13h-2.14ZM89.24,19.2V4.8h8.04v1.59h-6.23v4.5h5.86v1.58h-5.86v5.14h6.23v1.59h-8.04ZM107.74,14.74h-5.7l-1.71,4.46h-1.83l5.62-14.46h1.63l5.6,14.46h-1.88s-1.73-4.46-1.73-4.46ZM105.58,8.77c-.25-.71-.47-1.43-.69-2.16-.18.73-.39,1.45-.62,2.16l-1.63,4.36h4.55s-1.61-4.36-1.61-4.36Z" style="fill:#000;"/></svg>

+ 274
- 0
app/assets/stylesheets/admin.css View File

@ -0,0 +1,274 @@
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
src: url('MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2') format('woff2');
}
:root {
--icon: #777;
--icon-active: #f1f1f1;
--white: #e6edf3;
--black: #2d2d2d;
--error: #b24226;
--bg: #fff;
--audit: #888;
--inactive: #888;
--secondary: #888;
--action: #3c85c6;
--call-to-action: #D65535;
--enabled: rgb(26, 134, 58);
--disabled: #b24226;
--border: #ccc;
--hover: #f6f6f6;
--popup-bg: #282a2c;
--clr-black: #2d2d2d;
--clr-grey-100: #f4f4f5;
--clr-grey-200: #e4e4e7;
--clr-grey-300: #d4d4d8;
--clr-grey-400: #a1a1aa;
--clr-grey-500: #71717a;
--clr-grey-600: #52525b;
--clr-grey-700: #3f3f46;
--clr-grey-750: #2e2f31;
--font-icons: 'Material Symbols Outlined';
--font-base: system-ui, sans-serif;
--font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace;
font: 10px/1.3 var(--font-base);
}
/*.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
}*/
@keyframes rotate_animation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
body {
margin: 0;
background: var(--bg, #fff);
color: var(--black, #000);
-webkit-font-smoothing: antialiased;
font-optical-sizing: auto;
}
.icon, .node__flags {
font-family: var(--font-icons);
font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
&.action {
color: var(--action);
}
&.enabled {
color: var(--enabled);
}
&.disabled {
color: var(--disabled);
}
&.size--small {
font-size: 18px;
}
&.size--medium {
font-size: 20px;
}
}
a.icon {
font-family: var(--font-icons);
font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
font-weight: 400;
display: inline-flex;
justify-content: center;
align-items: center;
text-decoration: none;
width: 36px;
height: 36px;
border-radius: 50%;
color: var(--clr-grey-600);
&:hover {
color: var(--black);
background-color: var(--clr-grey-200);
}
}
button {
cursor: pointer;
}
#navbar {
background: var(--clr-grey-750);
position: fixed;
z-index: 999;
width: 80px;
inset: 0 auto 0 0;
padding: 10px 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 20px;
&>div {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
}
.navbar-link {
background: none;
border: none;
color: var(--icon);
text-decoration: none;
& .icon {
font-size: 32px;
}
&.open,
&.current {
.icon {
color: var(--icon-active);
}
}
}
turbo-frame {
display: block;
}
#main {
margin: 0 0 0 80px;
padding: 30px 40px;
}
h1,
.node-path {
font-size: 2.4rem;
line-height: 2;
font-weight: 400;
margin: 0 0 0 0;
}
.list-title-link {
text-decoration: none;
color: var(--black);
padding: 0 0.6em;
border-radius: 999px;
display: inline-block;
appearance: none;
background-color: transparent;
border: none;
font-size: inherit;
line-height: inherit;
}
a.list-title-link:hover {
background-color: var(--clr-grey-200);
}
#flash {
position: fixed;
left: 80px;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
gap: 0px;
font-size: 1.6rem;
z-index: 100;
}
.flash__message {
color: var(--white, #fff);
background-color: rgba(119, 119, 119, 0.8);
animation: appear-then-fade 2s both;
padding: 10px 20px;
display: flex;
align-items: center;
gap: 8px;
& .icon {
color: #f1f1f1;
font-size: 3.2rem;
}
}
@keyframes appear-then-fade {
0%,
100% {
opacity: 0;
}
2%,
90% {
opacity: 1;
}
}
.logo {
display: block;
transform-origin: center center;
width: 40px;
aspect-ratio: 40/368;
& svg {
rotate: 90deg;
display: block;
transform-origin: 0 0;
transform: translateY(-100%);
height: 40px;
}
}

+ 134
- 0
app/assets/stylesheets/assets.css View File

@ -0,0 +1,134 @@
#assets {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
}
.asset {
text-align: center;
}
.asset__thumbnail {
position: relative;
width: 100%;
margin-bottom: 12px;
&::after {
content: "";
display: block;
padding-bottom: 100%;
}
& div {
position: absolute;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
& img {
display: block;
max-width: 100%;
max-height: 100%;
box-shadow: rgba(0, 0, 0, 0.14) 0px 2px 2px 0px, rgba(0, 0, 0, 0.12) 0px 3px 1px -2px, rgba(0, 0, 0, 0.2) 0px 1px 5px 0px;
}
}
.asset__title {
font-size: 1.4rem;
margin-bottom: 2px;
text-overflow: ellipsis;
white-space: nowrap;
word-break: normal;
overflow: hidden;
}
.asset__mimetypes {
font-size: 1.2rem;
font-family: var(--font-mono);
color: var(--action);
}
.assets-sort {
font-size: 1.4rem;
display: flex;
justify-content: flex-end;
gap: 1rem;
align-items: center;
.popup-menu {
min-width: auto
}
& a {
font-size: 1.4rem;
white-space: nowrap;
}
}
.asset {
position: relative;
padding: 10px 10px 40px 10px;
border: 2px solid transparent;
&:hover {
border-color: var(--clr-grey-200);
border-radius: 10px;
& .asset-ctrls {
bottom: 6px;
position: absolute;
left: 10px;
right: 10px;
display: flex;
justify-content: space-between;
}
}
}
.icon-cb-round {
position: absolute;
inset: 0 0 0 0;
cursor: pointer;
display: none;
& span {
position: absolute;
right: 8px;
top: 8px;
width: 16px;
height: 16px;
outline: 2px solid var(--border);
outline-offset: 2px;
border-radius: 50%;
}
& input {
position: absolute;
height: 0;
width: 0;
clip: rect(0,0,0,0);
&:checked~span {
outline: 2px solid var(--action);
background-color: var(--action);
}
}
}
#overlay {
& .icon-cb-round {
display: block;
}
& .asset-ctrls {
display: none;
}
}

+ 142
- 0
app/assets/stylesheets/attachments.css View File

@ -0,0 +1,142 @@
#attachments {
display: flex;
flex-direction: column;
gap: 3.2rem;
}
.attachment {
position: relative;
padding-top: 14px;
& label {
font-size: 1.4rem;
line-height: 1.2;
color: #555;
display: block;
margin-bottom: 8px;
}
& trix-editor {
min-height: 15em;
/* max-height: 320px;
overflow: auto; */
}
.handle {
appearance: none;
position: absolute;
top: 4px;
right: 40px;
border: none;
border-radius: 50%;
display: flex;
width: 32px;
height: 32px;
justify-content: center;
align-items: center;
font-family: var(--font-icons);
background-color: transparent;
font-size: 2.2rem;
color: var(--clr-grey-400);
&:hover {
background-color: var(--clr-grey-200);
color: var(--clr-grey-700);
}
}
}
.attachment__content:has(.attachment__asset) {
display: grid;
grid-template-columns: 120px 1fr;
gap: 0 20px;
& .trix-button--icon-decrease-nesting-level,
.trix-button--icon-increase-nesting-level,
.trix-button--icon-bullet-list,
.trix-button--icon-number-list {
display: none;
}
& trix-editor {
min-height: 8em;
}
.attachment__content-two {
grid-column-start: 2;
}
.attachment__content-three {
grid-column-start: 2;
}
}
.attachment-site:not(:has(.attachment__asset)) {
& .attachment__content-three {
&>*:nth-child(3),
&>*:nth-child(4) {
display: none;
}
}
}
.attachment-site:has(.attachment__asset) {
& .attachment__content-three {
&>*:nth-child(1),
&>*:nth-child(2) {
display: none;
}
}
}
.attachment-tile {
& .attachment__content-three {
&>*:nth-child(4) {
display: none;
}
}
}
#newsletter-attachments .attachment__content .field:nth-child(2) {
display: none;
}
#newsletter-attachments .attachment__content:has(.attachment__asset) {
& .attachment__content-three {
display: none;
}
}
.attachment__content .field {
display: flex;
flex-direction: column;
padding-bottom: 0;
&>div:nth-child(2) {
flex-basis: 100%;
flex-grow: 1;
width: 100%;
}
}
.attachment__content-two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.attachment__content-three {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0 20px;
}
.attachment__asset {
margin-top: 3em;
text-align: center;
}
.trix__field {
width: 100%;
}

+ 158
- 0
app/assets/stylesheets/form.css View File

@ -0,0 +1,158 @@
.subscribe__container {
min-height: 30svh;
}
.field {
display: flex;
flex-direction: column;
gap: 0.4em;
&>div:nth-child(1) {
& label {
font-size: 1.6rem;
line-height: 1.2;
color: var(--clr-grey-600);
}
}
& .field_with_errors {
--border: var(--clr-error);
}
& + .field,
& + .input__fields-two {
margin-top: 1.2em;
}
&:has(input[type=submit]) {
margin: 2em 0;
display: block;
}
}
input[type=submit] {
font-family: var(--ff-base);
font-size: 1.6rem;
background-color: var(--clr-grey-800);
color: var(--clr-white-200);
padding: 1em 1.2em;
border: none;
appearance: none;
border-radius: 2px;
cursor: pointer;
}
.material__input {
display: flex;
font-family: var(--ff-base);
border: 1px solid var(--clr-border);
font-size: 1.6rem;
letter-spacing: 0.00625em;
border: none !important;
background-color: transparent;
width: 100%;
height: 100%;
appearance: none;
padding: 0;
box-shadow: none;
&:focus {
outline: none;
}
}
textarea.material__input {
resize: none;
}
.input-box {
display: flex;
height: 46px;
width: 100%;
box-sizing: border-box;
align-items: center;
padding: 0 16px;
margin: 0;
overflow: visible;
-webkit-box-align: baseline;
--outline: var(--clr-action);
border-radius: 4px;
border: 1px solid var(--clr-border);
& .field_with_errors {
display: flex;
height: 100%;
width: 100%;
}
&:has(textarea) {
padding: 0 0 0 8px;
height: auto;
& textarea {
line-height: 1.2;
margin: 1px 0;
padding: 8px 16px 8px 0;
}
}
&:hover {
border-color: var(--clr-grey-500);
}
&:focus-within {
outline: 2px solid var(--outline);
outline-offset: -2px;
}
&:has(.field_with_errors) {
--outline: var(--clr-error);
&:hover {
border-color: var(--clr-error);
}
}
}
p[role="alert"] {
margin: 4px 0 0 0;
color: var(--clr-error);
font-size: 1.2rem;
}
ul.errors {
color: var(--clr-error);
margin: 0 0 1.2em 0;
}
p[role="tooltip"] {
margin: 4px 0 0 0;
color: var(--clr-grey-500);
font-size: 1.2rem;
}
.form__errors {
margin: 0 0 1.2em 0;
color: var(--clr-error);
padding: 0;
list-style: none;
}
.input__fields-two {
display: grid;
gap: 2em;
grid-template-columns: 1fr 2fr;
& .field + .field {
margin-top: 0;
}
}
.input__fields-two + .field {
margin-top: 1.2em;
}

+ 1061
- 0
app/assets/stylesheets/forms.css
File diff suppressed because it is too large
View File


+ 436
- 0
app/assets/stylesheets/lists.css View File

@ -0,0 +1,436 @@
.entries-info {
font-size: 2.2rem;
font-weight: 500;
margin: 0;
}
.audit-date, .user__role {
color: var(--audit);
font-size: 1.4rem;
}
.date {
color: var(--audit);
font-size: 1.4rem;
}
.list-container {
padding-top: 0em;
margin-top: 2em;
}
.list-ctrls {
display: flex;
justify-content: space-between;
align-items: center;
}
.list {
padding: 2em 0;
}
.list-small {
padding: 0;
& .list-header, & .row, & .audit-date {
font-size: 1.2rem;
}
& .icon {
font-size: 1.8rem;
}
}
.list-header,
.row {
display: flex;
justify-content: stretch;
align-items: center;
}
.list-header {
font-size: 1.4rem;
line-height: 1.2;
color: var(--black);
min-height: 38px;
border-bottom: 1px solid var(--border);
}
.sort_link {
font-size: 1.4rem;
text-decoration: none;
color: var(--black);
display: inline-flex;
align-items: center;
gap: 0.4em;
border-radius: 999px;
padding: 0.4em 1em;
margin-left: -1em;
&:hover {
background-color: var(--clr-grey-200);
}
&.current {
position: relative;
color: #000;
&::after {
content: "arrow_downward";
font: 1.6rem var(--font-icons);
font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
color: var(--black);
}
&.desc::after {
content: "arrow_upward";
}
}
}
.popup-menu .sort_link {
border-radius: 0px;
margin-left: 0;
}
.row {
border-bottom: 1px solid var(--border);
min-height: 48px;
box-sizing: border-box;
padding: 4px 0;
font-size: 1.6rem;
& .actions {
& a,
button {
display: none;
}
}
&.sortable-drag {
border-bottom: none;
background-color: var(--bg);
& .handle {
display: inline-flex;
}
}
}
.handle {
font-family: var(--font-icons);
font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
display: none;
font-size: 20px;
justify-content: center;
align-items: center;
text-decoration: none;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: move;
&:hover {
background-color: var(--clr-grey-200);
}
}
.list:not(:has(.row.sortable-chosen)) {
& .row:hover {
background-color: var(--hover);
& .actions {
& a,
button,
.handle {
display: inline-flex;
flex-shrink: 0;
}
}
}
}
.cell {
padding: 0 8px;
flex: 1 1 0px;
& ul {
margin: 8px 0;
padding: 0;
list-style: none;
& li {
display: flex;
align-items: center;
gap: 0.4em;
}
}
&.flags {
flex: 0 0 auto;
width: 40px;
}
&.with-cb {
width: 40px;
flex: 0 0 auto;
}
&.mono {
font-size: 1.2rem;
font-family: var(--font-mono);
}
&.actions {
flex: 0 0 auto;
text-align: right;
width: 72px;
display: flex;
align-items: center;
justify-content: flex-end;
}
&.actions-one {
width: 40px;
}
}
.node__flags {
font-size: 20px;
color: var(--clr-grey-400);
font-variation-settings: 'FILL' 0;
display: flex;
gap: 0;
}
.back-link {
color: var(--secondary);
text-decoration: none;
font-size: 1.6rem;
display: inline-flex;
justify-content: center;
align-items: center;
&::before {
content: "arrow_back";
font: 1.6rem/1 var(--font-icons);
color: var(--secondary);
margin-right: 0.4em;
}
}
.pagination-container {
color: var(--inactive);
padding: 20px 0;
display: flex;
justify-content: end;
align-items: center;
gap: 20px;
font-size: 1.4rem;
}
.pagination {
font: normal 3.2rem/40px var(--font-icons);
font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
display: flex;
height: 40px;
margin-right: 8px;
align-items: center;
& span {
color: var(--border);
width: 40px;
height: 40px;
}
& a {
display: block;
width: 40px;
height: 40px;
text-align: center;
color: var(--secondary);
text-decoration: none;
&:hover {
color: var(--black);
}
}
}
.category-selector {
font-size: 1.5rem;
line-height: 1.6;
display: flex;
flex-wrap: wrap;
gap: 0.4em;
& a {
color: var(--black, #000);
text-decoration: none;
padding: 0.4em 0.8em;
border-radius: 999px;
background-color: var(--clr-grey-200);
&.current {
background-color: var(--clr-grey-750, #000);
color: var(--white, #FFF);
& span::before {
content: 'done';
font-family: var(--font-icons);
font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
font-size: 2.4rem;
line-height: 1;
margin-right: 2px;
vertical-align: bottom;
}
}
}
}
.list-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-left: -0.6em;
}
.node-path {
display: flex;
flex-wrap: wrap;
gap: 0.2em;
&>* {
height: 48px;
}
& .list-title-link[data-icon] {
display: inline-flex;
gap: 6px;
align-items: center;
&::before {
color: var(--data-icon-color, #717274);
font-family: var(--font-icons);
font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
font-size: 3.2rem;
line-height: 1;
content: attr(data-icon);
}
}
& .has-popup-menu {
&::after {
color: var(--black);
font-family: var(--font-icons);
font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
font-size: 2.8rem;
line-height: 32px;
width: 20px;
overflow: visible;
content: 'expand_more';
}
}
}
.list-title .flex {
display: flex;
gap: 1em;
& form {
padding: 0;
display: flex;
align-items: center;
gap: 4px;
&>div {
display: flex;
flex-shrink: 0;
flex-grow: 1;
height: 48px;
background-color: var(--clr-grey-200);
border-radius: 999px;
padding: 0 4px 0 1em;
justify-content: start;
align-items: center;
gap: 8px;
max-width: 300px;
&:focus-within {
outline: 2px solid var(--action);
}
}
& input[type="text"] {
background-color: transparent;
border: none;
flex-grow: 1;
font-size: 1.6rem;
line-height: 36px;
height: 36px;
padding: 0 0.4em;
outline: none;
}
& button,
a {
font-size: 28px;
line-height: 40px;
text-align: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
background: none;
color: var(--inactive);
outline: none;
border-radius: 50%;
&:focus {
background-color: var(--clr-grey-300);
color: var(--black);
}
&:hover {
background-color: var(--clr-grey-300);
color: var(--black);
}
}
}
}
.search__collection:has(input:placeholder-shown) {
& .reset__search {
display: none;
}
}
#load-more {
height: 1em;
}
.utm {
margin-top: 8px;
font-size: 1.2rem;
font-family: var(--font-mono);
& .tag {
font-size: 1.2rem;
}
}

+ 238
- 0
app/assets/stylesheets/nodes.css View File

@ -0,0 +1,238 @@
#drawer {
position: fixed;
top: 0;
left: 80px;
width: 320px;
bottom: 0;
padding: 16px 0;
background-color: #28292A;
color: var(--white, #FFF);
overflow: auto;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background-color: #28292A;
}
&::-webkit-scrollbar-thumb {
background-color: #BDC1C6;
outline: 1px solid #BDC1C6;
border-radius: 8px;
}
& h1 {
margin: 0;
font: 1.4rem/1 var(--font-base);
}
&:hover {
--clr-spacer: #444;
}
}
#drawer-structure {
font-size: 1.4rem;
line-height: 1.5;
margin: 0 8px;
}
.node-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 8px 10px;
}
.node {
& a {
color: var(--white, #fff);
text-decoration: none;
}
&.current>.node-row:nth-child(1) {
background-color: #37383A;
}
}
.node-row {
--level: 0;
--toggle-width: 1.6rem;
display: grid;
grid-template-columns: calc(var(--level) * (var(--toggle-width) / 2)) var(--toggle-width) 1fr;
grid-template-areas: "spacer toggle content";
min-height: 3.2rem;
padding: 0 10px 0 0;
cursor: pointer;
&:hover {
background-color: #37383A;
border-radius: 4px;
}
.child {
color: var(--clr-grey-400);
font: 1.8rem/1 var(--font-icons);
font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
grid-area: toggle;
display: flex;
align-items: center;
justify-content: center;
}
.parent {
&:hover {
background-color: var(--clr-grey-600);
border-radius: 4px 0 0 4px;
}
& span:nth-child(1) {
display: inherit;
}
& span:nth-child(2) {
display: none;
}
}
&.closed {
.parent {
& span:nth-child(1) {
display: none;
}
& span:nth-child(2) {
display: inherit;
}
}
}
}
.node-title {
display: flex;
align-items: center;
gap: 4px;
grid-area: content;
letter-spacing: 0.00625em;
text-overflow: ellipsis;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-break: normal;
&::before {
color: var(--data-icon-color, #717274);
font-family: var(--font-icons);
font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
font-size: 2.0rem;
content: attr(data-icon);
}
}
.node-link {
text-decoration: none;
color: var(--black);
display: flex;
align-items: center;
gap: 8px;
&:not(:has(img))::before {
color: var(--data-icon-color, #717274);
font-family: var(--font-icons);
font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
font-size: 2.4rem;
line-height: 1.5;
content: attr(data-icon);
}
&:has(img) {
& img {
width: 24px;
height: 36px;
object-fit: contain;
}
}
}
[data-icon=inventory_2] {
--data-icon-color: #886F65;
}
[data-icon=folder] {
--data-icon-color: #EC5F59;
}
[data-icon=book_2] {
--data-icon-color: #F8CA4F;
}
[data-icon=description] {
--data-icon-color: #94A3AD;
}
.spacer {
grid-area: spacer;
display: flex;
justify-content: stretch;
& div {
width: 100%;
height: 100%;
border-right: 1px solid var(--clr-spacer, transparent);
}
}
.closed~.node {
display: none;
}
.node-ctrls {
display: flex;
gap: 8px;
& button {
font: 1.6rem/1 var(--font-icons);
font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
}
}
#tree {
margin-left: 320px;
}
.tree-title {
font-size: 1.6rem;
font-weight: 600;
display: flex;
justify-content: start;
gap: 0.2em;
& a {
color: var(--action);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.tree-children {
font-size: 1.6rem;
}

+ 86
- 0
app/assets/stylesheets/popup-menu.css View File

@ -0,0 +1,86 @@
.popup-menu {
position: absolute;
display: none;
background-color: var(--bg);
padding: 0;
overflow: hidden;
border-radius: 4px;
font-size: 1.6rem;
line-height: 1.2;
min-width: 240px;
&.open {
display: block;
z-index: 1000;
box-shadow: 0px 0px 1.5px rgba(0, 0, 0, 0.105), 0px 1px 3px rgba(0, 0, 0, 0.21);
}
& ul {
margin: 0;
padding: 0;
list-style: none;
& .with-icon {
display: flex;
justify-content: space-between;
}
}
& li>* {
display: flex;
align-items: center;
justify-content: space-between;
}
& li>div {
padding: 2px 12px;
min-height: 40px;
}
& form.button_to {
display: block;
}
& a, button[type="submit"] {
display: flex;
appearance: none;
border: none;
font-size: 1.6rem;
justify-content: flex-start;
flex-grow: 1;
align-items: center;
color: var(--black, #000);
text-decoration: none;
gap: 0.4em;
padding: 2px 12px;
min-height: 40px;
&[data-icon]::before {
color: var(--data-icon-color);
font-family: var(--font-icons);
font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
font-size: 2.4rem;
line-height: 1;
content: attr(data-icon);
}
& .icon {
font-size: 2.8rem;
color: var(--clr-grey-800);
}
}
& a, button[type="submit"] {
&:hover {
background-color: var(--clr-grey-300);
}
}
& button[type="submit"] {
background-color: transparent;
width: 100%;
}
}

+ 172
- 0
app/assets/stylesheets/sessions.css View File

@ -0,0 +1,172 @@
:root {
font: 400 10px/1.3 system-ui, sans-serif;
--red: #B24226;
--border: #ccc;
--font-base: system-ui, sans-serif;
}
body {
background: #E4E4E4;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
}
html,
body {
height: 100%;
}
main {
max-width: 360px;
width: 100%;
background: white;
padding: 40px 32px 40px 32px;
font-size: 1.6rem;
box-shadow: 0 3px 3px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
gap: 20px;
}
svg {
display: block;
width: 100%;
height: auto;
margin: 0 auto;
}
label {
font-size: 1.4rem;
color: #666;
order: 0;
}
input[type="text"],
input[type="password"] {
display: flex;
border: 1px solid var(--border);
font-family: var(--font-base);
font-size: 1.6rem;
letter-spacing: 0.00625em;
border: none !important;
background-color: transparent;
width: 100%;
height: 100%;
appearance: none;
padding: 0;
box-shadow: none;
&:focus {
outline: none;
}
}
.input-box {
display: flex;
height: 46px;
width: 100%;
box-sizing: border-box;
align-items: baseline;
padding: 0 16px;
margin: 8px 0 0 0;
overflow: visible;
-webkit-box-align: baseline;
--outline: var(--action);
border-radius: 4px;
border: 1px solid var(--border);
& .field_with_errors {
display: flex;
height: 100%;
width: 100%;
}
&:hover {
border-color: var(--inactive);
}
&:focus-within {
outline: 2px solid var(--outline);
outline-offset: -2px;
}
&:has(.field_with_errors) {
--outline: var(--error);
&:hover {
border-color: var(--error);
}
}
}
button[type=submit] {
background: #333;
border: none;
outline: none;
font: 400 1.6rem system-ui, sans-serif;
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
color: #FFF;
&:active {
background-color: #999;
}
}
p[role=alert] {
margin: 4px 0 0 0;
color: var(--red);
font-size: 1.4rem;
order: 3;
}
form {
margin: 0;
&>div {
margin-top: 32px;
display: flex;
flex-direction: column;
&:last-of-type {
text-align: right;
display: block;
}
}
}
.with-errors {
& label {
color: var(--red);
}
& input[type=text],
input[type=password] {
border-color: var(--red);
&:focus {
border-color: var(--red);
&+label {
color: var(--red);
}
}
}
}
footer {
text-align: right;
margin-top: 40px;
font-size: 1.4rem;
color: #666;
}

+ 412
- 0
app/assets/stylesheets/tom-select.css View File

@ -0,0 +1,412 @@
/**
* tom-select.css (v2.3.1)
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
* file except in compliance with the License. You may obtain a copy of the License at:
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
* ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*
*/
.ts-control {
border: 1px solid #d0d0d0;
padding: 8px 8px;
width: 100%;
overflow: hidden;
position: relative;
z-index: 1;
box-sizing: border-box;
box-shadow: none;
border-radius: 3px;
display: flex;
flex-wrap: wrap;
}
.ts-wrapper.multi.has-items .ts-control {
padding: calc(8px - 2px - 0) 8px calc(8px - 2px - 3px - 0);
}
.full .ts-control {
background-color: #fff;
}
.disabled .ts-control, .disabled .ts-control * {
cursor: default !important;
}
.focus .ts-control {
box-shadow: none;
}
.ts-control > * {
vertical-align: baseline;
display: inline-block;
}
.ts-wrapper.multi .ts-control > div {
cursor: pointer;
margin: 0 3px 3px 0;
padding: 2px 6px;
background: #f2f2f2;
color: #303030;
border: 0 solid #d0d0d0;
}
.ts-wrapper.multi .ts-control > div.active {
background: #e8e8e8;
color: #303030;
border: 0 solid #cacaca;
}
.ts-wrapper.multi.disabled .ts-control > div, .ts-wrapper.multi.disabled .ts-control > div.active {
color: #7d7d7d;
background: white;
border: 0 solid white;
}
.ts-control > input {
flex: 1 1 auto;
min-width: 7rem;
display: inline-block !important;
padding: 0 !important;
min-height: 0 !important;
max-height: none !important;
max-width: 100% !important;
margin: 0 !important;
text-indent: 0 !important;
border: 0 none !important;
background: none !important;
line-height: inherit !important;
-webkit-user-select: auto !important;
-moz-user-select: auto !important;
-ms-user-select: auto !important;
user-select: auto !important;
box-shadow: none !important;
}
.ts-control > input::-ms-clear {
display: none;
}
.ts-control > input:focus {
outline: none !important;
}
.has-items .ts-control > input {
margin: 0 4px !important;
}
.ts-control.rtl {
text-align: right;
}
.ts-control.rtl.single .ts-control:after {
left: 15px;
right: auto;
}
.ts-control.rtl .ts-control > input {
margin: 0 4px 0 -2px !important;
}
.disabled .ts-control {
opacity: 0.5;
background-color: #fafafa;
}
.input-hidden .ts-control > input {
opacity: 0;
position: absolute;
left: -10000px;
}
.ts-dropdown {
position: absolute;
top: 100%;
left: 0;
width: 100%;
z-index: 10;
border: 1px solid #d0d0d0;
background: #fff;
margin: 0.25rem 0 0;
border-top: 0 none;
box-sizing: border-box;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border-radius: 0 0 3px 3px;
}
.ts-dropdown [data-selectable] {
cursor: pointer;
overflow: hidden;
}
.ts-dropdown [data-selectable] .highlight {
background: rgba(125, 168, 208, 0.2);
border-radius: 1px;
}
.ts-dropdown .option,
.ts-dropdown .optgroup-header,
.ts-dropdown .no-results,
.ts-dropdown .create {
padding: 5px 8px;
}
.ts-dropdown .option, .ts-dropdown [data-disabled], .ts-dropdown [data-disabled] [data-selectable].option {
cursor: inherit;
opacity: 0.5;
}
.ts-dropdown [data-selectable].option {
opacity: 1;
cursor: pointer;
}
.ts-dropdown .optgroup:first-child .optgroup-header {
border-top: 0 none;
}
.ts-dropdown .optgroup-header {
color: #303030;
background: #fff;
cursor: default;
}
.ts-dropdown .active {
background-color: #f5fafd;
color: #495c68;
}
.ts-dropdown .active.create {
color: #495c68;
}
.ts-dropdown .create {
color: rgba(48, 48, 48, 0.5);
}
.ts-dropdown .spinner {
display: inline-block;
width: 30px;
height: 30px;
margin: 5px 8px;
}
.ts-dropdown .spinner::after {
content: " ";
display: block;
width: 24px;
height: 24px;
margin: 3px;
border-radius: 50%;
border: 5px solid #d0d0d0;
border-color: #d0d0d0 transparent #d0d0d0 transparent;
animation: lds-dual-ring 1.2s linear infinite;
}
@keyframes lds-dual-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.ts-dropdown-content {
overflow: hidden auto;
max-height: 200px;
scroll-behavior: smooth;
}
.ts-wrapper.plugin-drag_drop .ts-dragging {
color: transparent !important;
}
.ts-wrapper.plugin-drag_drop .ts-dragging > * {
visibility: hidden !important;
}
.plugin-checkbox_options:not(.rtl) .option input {
margin-right: 0.5rem;
}
.plugin-checkbox_options.rtl .option input {
margin-left: 0.5rem;
}
/* stylelint-disable function-name-case */
.plugin-clear_button {
--ts-pr-clear-button: 1em;
}
.plugin-clear_button .clear-button {
opacity: 0;
position: absolute;
top: 50%;
transform: translateY(-50%);
right: calc(8px - 6px);
margin-right: 0 !important;
background: transparent !important;
transition: opacity 0.5s;
cursor: pointer;
}
.plugin-clear_button.form-select .clear-button, .plugin-clear_button.single .clear-button {
right: max(var(--ts-pr-caret), 8px);
}
.plugin-clear_button.focus.has-items .clear-button, .plugin-clear_button:not(.disabled):hover.has-items .clear-button {
opacity: 1;
}
.ts-wrapper .dropdown-header {
position: relative;
padding: 10px 8px;
border-bottom: 1px solid #d0d0d0;
background: color-mix(#fff, #d0d0d0, 85%);
border-radius: 3px 3px 0 0;
}
.ts-wrapper .dropdown-header-close {
position: absolute;
right: 8px;
top: 50%;
color: #303030;
opacity: 0.4;
margin-top: -12px;
line-height: 20px;
font-size: 20px !important;
}
.ts-wrapper .dropdown-header-close:hover {
color: black;
}
.plugin-dropdown_input.focus.dropdown-active .ts-control {
box-shadow: none;
border: 1px solid #d0d0d0;
}
.plugin-dropdown_input .dropdown-input {
border: 1px solid #d0d0d0;
border-width: 0 0 1px;
display: block;
padding: 8px 8px;
box-shadow: none;
width: 100%;
background: transparent;
}
.plugin-dropdown_input .items-placeholder {
border: 0 none !important;
box-shadow: none !important;
width: 100%;
}
.plugin-dropdown_input.has-items .items-placeholder, .plugin-dropdown_input.dropdown-active .items-placeholder {
display: none !important;
}
.ts-wrapper.plugin-input_autogrow.has-items .ts-control > input {
min-width: 0;
}
.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input {
flex: none;
min-width: 4px;
}
.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input::-ms-input-placeholder {
color: transparent;
}
.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input::placeholder {
color: transparent;
}
.ts-dropdown.plugin-optgroup_columns .ts-dropdown-content {
display: flex;
}
.ts-dropdown.plugin-optgroup_columns .optgroup {
border-right: 1px solid #f2f2f2;
border-top: 0 none;
flex-grow: 1;
flex-basis: 0;
min-width: 0;
}
.ts-dropdown.plugin-optgroup_columns .optgroup:last-child {
border-right: 0 none;
}
.ts-dropdown.plugin-optgroup_columns .optgroup::before {
display: none;
}
.ts-dropdown.plugin-optgroup_columns .optgroup-header {
border-top: 0 none;
}
.ts-wrapper.plugin-remove_button .item {
display: inline-flex;
align-items: center;
}
.ts-wrapper.plugin-remove_button .item .remove {
color: inherit;
text-decoration: none;
vertical-align: middle;
display: inline-block;
padding: 0 6px;
border-radius: 0 2px 2px 0;
box-sizing: border-box;
}
.ts-wrapper.plugin-remove_button .item .remove:hover {
background: rgba(0, 0, 0, 0.05);
}
.ts-wrapper.plugin-remove_button.disabled .item .remove:hover {
background: none;
}
.ts-wrapper.plugin-remove_button .remove-single {
position: absolute;
right: 0;
top: 0;
font-size: 23px;
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item {
padding-right: 0 !important;
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
border-left: 1px solid #d0d0d0;
margin-left: 6px;
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item.active .remove {
border-left-color: #cacaca;
}
.ts-wrapper.plugin-remove_button:not(.rtl).disabled .item .remove {
border-left-color: white;
}
.ts-wrapper.plugin-remove_button.rtl .item {
padding-left: 0 !important;
}
.ts-wrapper.plugin-remove_button.rtl .item .remove {
border-right: 1px solid #d0d0d0;
margin-right: 6px;
}
.ts-wrapper.plugin-remove_button.rtl .item.active .remove {
border-right-color: #cacaca;
}
.ts-wrapper.plugin-remove_button.rtl.disabled .item .remove {
border-right-color: white;
}
:root {
--ts-pr-clear-button: 0;
--ts-pr-caret: 0;
--ts-pr-min: .75rem;
}
.ts-wrapper.single .ts-control, .ts-wrapper.single .ts-control input {
cursor: pointer;
}
.ts-control:not(.rtl) {
padding-right: max(var(--ts-pr-min), var(--ts-pr-clear-button) + var(--ts-pr-caret)) !important;
}
.ts-control.rtl {
padding-left: max(var(--ts-pr-min), var(--ts-pr-clear-button) + var(--ts-pr-caret)) !important;
}
.ts-wrapper {
position: relative;
}
.ts-dropdown,
.ts-control,
.ts-control input {
color: #303030;
font-family: inherit;
font-size: 13px;
line-height: 18px;
}
.ts-control,
.ts-wrapper.single.input-active .ts-control {
background: #fff;
cursor: text;
}
.ts-hidden-accessible {
border: 0 !important;
clip: rect(0 0 0 0) !important;
-webkit-clip-path: inset(50%) !important;
clip-path: inset(50%) !important;
overflow: hidden !important;
padding: 0 !important;
position: absolute !important;
width: 1px !important;
white-space: nowrap !important;
}
/*# sourceMappingURL=tom-select.css.map */

+ 410
- 0
app/assets/stylesheets/trix.css View File

@ -0,0 +1,410 @@
trix-editor {
border: 1px solid #bbb;
border-radius: 3px;
margin: 0;
padding: 0.4em 0.6em;
min-height: 5em;
outline: none; }
trix-toolbar * {
box-sizing: border-box; }
trix-toolbar .trix-button-row {
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
overflow-x: auto; }
trix-toolbar .trix-button-group {
display: flex;
margin-bottom: 10px;
border: 1px solid #bbb;
border-top-color: #ccc;
border-bottom-color: #888;
border-radius: 3px; }
trix-toolbar .trix-button-group:not(:first-child) {
margin-left: 1.5vw; }
@media (max-device-width: 768px) {
trix-toolbar .trix-button-group:not(:first-child) {
margin-left: 0; } }
trix-toolbar .trix-button-group-spacer {
flex-grow: 1; }
@media (max-device-width: 768px) {
trix-toolbar .trix-button-group-spacer {
display: none; } }
trix-toolbar .trix-button {
position: relative;
float: left;
color: rgba(0, 0, 0, 0.6);
font-size: 0.75em;
font-weight: 600;
white-space: nowrap;
padding: 0 0.5em;
margin: 0;
outline: none;
border: none;
border-bottom: 1px solid #ddd;
border-radius: 0;
background: transparent; }
trix-toolbar .trix-button:not(:first-child) {
border-left: 1px solid #ccc; }
trix-toolbar .trix-button.trix-active {
background: #cbeefa;
color: black; }
trix-toolbar .trix-button:not(:disabled) {
cursor: pointer; }
trix-toolbar .trix-button:disabled {
color: rgba(0, 0, 0, 0.125); }
@media (max-device-width: 768px) {
trix-toolbar .trix-button {
letter-spacing: -0.01em;
padding: 0 0.3em; } }
trix-toolbar .trix-button--icon {
font-size: inherit;
width: 2.6em;
height: 1.6em;
max-width: calc(0.8em + 4vw);
text-indent: -9999px; }
@media (max-device-width: 768px) {
trix-toolbar .trix-button--icon {
height: 2em;
max-width: calc(0.8em + 3.5vw); } }
trix-toolbar .trix-button--icon::before {
display: inline-block;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0.6;
content: "";
background-position: center;
background-repeat: no-repeat;
background-size: contain; }
@media (max-device-width: 768px) {
trix-toolbar .trix-button--icon::before {
right: 6%;
left: 6%; } }
trix-toolbar .trix-button--icon.trix-active::before {
opacity: 1; }
trix-toolbar .trix-button--icon:disabled::before {
opacity: 0.125; }
trix-toolbar .trix-button--icon-attach::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M10.5%2018V7.5c0-2.25%203-2.25%203%200V18c0%204.125-6%204.125-6%200V7.5c0-6.375%209-6.375%209%200V18%22%20stroke%3D%22%23000%22%20stroke-width%3D%222%22%20stroke-miterlimit%3D%2210%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E");
top: 8%;
bottom: 4%; }
trix-toolbar .trix-button--icon-bold::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M6.522%2019.242a.5.5%200%200%201-.5-.5V5.35a.5.5%200%200%201%20.5-.5h5.783c1.347%200%202.46.345%203.24.982.783.64%201.216%201.562%201.216%202.683%200%201.13-.587%202.129-1.476%202.71a.35.35%200%200%200%20.049.613c1.259.56%202.101%201.742%202.101%203.22%200%201.282-.483%202.334-1.363%203.063-.876.726-2.132%201.12-3.66%201.12h-5.89ZM9.27%207.347v3.362h1.97c.766%200%201.347-.17%201.733-.464.38-.291.587-.716.587-1.27%200-.53-.183-.928-.513-1.198-.334-.273-.838-.43-1.505-.43H9.27Zm0%205.606v3.791h2.389c.832%200%201.448-.177%201.853-.497.399-.315.614-.786.614-1.423%200-.62-.22-1.077-.63-1.385-.418-.313-1.053-.486-1.905-.486H9.27Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-italic::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M9%205h6.5v2h-2.23l-2.31%2010H13v2H6v-2h2.461l2.306-10H9V5Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-link::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M18.948%205.258a4.337%204.337%200%200%200-6.108%200L11.217%206.87a.993.993%200%200%200%200%201.41c.392.39%201.027.39%201.418%200l1.623-1.613a2.323%202.323%200%200%201%203.271%200%202.29%202.29%200%200%201%200%203.251l-2.393%202.38a3.021%203.021%200%200%201-4.255%200l-.05-.049a1.007%201.007%200%200%200-1.418%200%20.993.993%200%200%200%200%201.41l.05.049a5.036%205.036%200%200%200%207.091%200l2.394-2.38a4.275%204.275%200%200%200%200-6.072Zm-13.683%2013.6a4.337%204.337%200%200%200%206.108%200l1.262-1.255a.993.993%200%200%200%200-1.41%201.007%201.007%200%200%200-1.418%200L9.954%2017.45a2.323%202.323%200%200%201-3.27%200%202.29%202.29%200%200%201%200-3.251l2.344-2.331a2.579%202.579%200%200%201%203.631%200c.392.39%201.027.39%201.419%200a.993.993%200%200%200%200-1.41%204.593%204.593%200%200%200-6.468%200l-2.345%202.33a4.275%204.275%200%200%200%200%206.072Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-strike::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M6%2014.986c.088%202.647%202.246%204.258%205.635%204.258%203.496%200%205.713-1.728%205.713-4.463%200-.275-.02-.536-.062-.781h-3.461c.398.293.573.654.573%201.123%200%201.035-1.074%201.787-2.646%201.787-1.563%200-2.773-.762-2.91-1.924H6ZM6.432%2010h3.763c-.632-.314-.914-.715-.914-1.273%200-1.045.977-1.739%202.432-1.739%201.475%200%202.52.723%202.617%201.914h2.764c-.05-2.548-2.11-4.238-5.39-4.238-3.145%200-5.392%201.719-5.392%204.316%200%20.363.04.703.12%201.02ZM4%2011a1%201%200%201%200%200%202h15a1%201%200%201%200%200-2H4Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-quote::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M4.581%208.471c.44-.5%201.056-.834%201.758-.995C8.074%207.17%209.201%207.822%2010%208.752c1.354%201.578%201.33%203.555.394%205.277-.941%201.731-2.788%203.163-4.988%203.56a.622.622%200%200%201-.653-.317c-.113-.205-.121-.49.16-.764.294-.286.567-.566.791-.835.222-.266.413-.54.524-.815.113-.28.156-.597.026-.908-.128-.303-.39-.524-.72-.69a3.02%203.02%200%200%201-1.674-2.7c0-.905.283-1.59.72-2.088Zm9.419%200c.44-.5%201.055-.834%201.758-.995%201.734-.306%202.862.346%203.66%201.276%201.355%201.578%201.33%203.555.395%205.277-.941%201.731-2.789%203.163-4.988%203.56a.622.622%200%200%201-.653-.317c-.113-.205-.122-.49.16-.764.294-.286.567-.566.791-.835.222-.266.412-.54.523-.815.114-.28.157-.597.026-.908-.127-.303-.39-.524-.72-.69a3.02%203.02%200%200%201-1.672-2.701c0-.905.283-1.59.72-2.088Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-heading-1::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.5%207.5v-3h-12v3H14v13h3v-13h4.5ZM9%2013.5h3.5v-3h-10v3H6v7h3v-7Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-code::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M3.293%2011.293a1%201%200%200%200%200%201.414l4%204a1%201%200%201%200%201.414-1.414L5.414%2012l3.293-3.293a1%201%200%200%200-1.414-1.414l-4%204Zm13.414%205.414%204-4a1%201%200%200%200%200-1.414l-4-4a1%201%200%201%200-1.414%201.414L18.586%2012l-3.293%203.293a1%201%200%200%200%201.414%201.414Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-bullet-list::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5%207.5a1.5%201.5%200%201%200%200-3%201.5%201.5%200%200%200%200%203ZM8%206a1%201%200%200%201%201-1h11a1%201%200%201%201%200%202H9a1%201%200%200%201-1-1Zm1%205a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm0%206a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm-2.5-5a1.5%201.5%200%201%201-3%200%201.5%201.5%200%200%201%203%200ZM5%2019.5a1.5%201.5%200%201%200%200-3%201.5%201.5%200%200%200%200%203Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-number-list::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M3%204h2v4H4V5H3V4Zm5%202a1%201%200%200%201%201-1h11a1%201%200%201%201%200%202H9a1%201%200%200%201-1-1Zm1%205a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm0%206a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm-3.5-7H6v1l-1.5%202H6v1H3v-1l1.667-2H3v-1h2.5ZM3%2017v-1h3v4H3v-1h2v-.5H4v-1h1V17H3Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-undo::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M3%2014a1%201%200%200%200%201%201h6a1%201%200%201%200%200-2H6.257c2.247-2.764%205.151-3.668%207.579-3.264%202.589.432%204.739%202.356%205.174%205.405a1%201%200%200%200%201.98-.283c-.564-3.95-3.415-6.526-6.825-7.095C11.084%207.25%207.63%208.377%205%2011.39V8a1%201%200%200%200-2%200v6Zm2-1Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-redo::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21%2014a1%201%200%200%201-1%201h-6a1%201%200%201%201%200-2h3.743c-2.247-2.764-5.151-3.668-7.579-3.264-2.589.432-4.739%202.356-5.174%205.405a1%201%200%200%201-1.98-.283c.564-3.95%203.415-6.526%206.826-7.095%203.08-.513%206.534.614%209.164%203.626V8a1%201%200%201%201%202%200v6Zm-2-1Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-decrease-nesting-level::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5%206a1%201%200%200%201%201-1h12a1%201%200%201%201%200%202H6a1%201%200%200%201-1-1Zm4%205a1%201%200%201%200%200%202h9a1%201%200%201%200%200-2H9Zm-3%206a1%201%200%201%200%200%202h12a1%201%200%201%200%200-2H6Zm-3.707-5.707a1%201%200%200%200%200%201.414l2%202a1%201%200%201%200%201.414-1.414L4.414%2012l1.293-1.293a1%201%200%200%200-1.414-1.414l-2%202Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-button--icon-increase-nesting-level::before {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5%206a1%201%200%200%201%201-1h12a1%201%200%201%201%200%202H6a1%201%200%200%201-1-1Zm4%205a1%201%200%201%200%200%202h9a1%201%200%201%200%200-2H9Zm-3%206a1%201%200%201%200%200%202h12a1%201%200%201%200%200-2H6Zm-2.293-2.293%202-2a1%201%200%200%200%200-1.414l-2-2a1%201%200%201%200-1.414%201.414L3.586%2012l-1.293%201.293a1%201%200%201%200%201.414%201.414Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); }
trix-toolbar .trix-dialogs {
position: relative; }
trix-toolbar .trix-dialog {
position: absolute;
top: 0;
left: 0;
right: 0;
font-size: 0.75em;
padding: 15px 10px;
background: #fff;
box-shadow: 0 0.3em 1em #ccc;
border-top: 2px solid #888;
border-radius: 5px;
z-index: 5; }
trix-toolbar .trix-input--dialog {
font-size: inherit;
font-weight: normal;
padding: 0.5em 0.8em;
margin: 0 10px 0 0;
border-radius: 3px;
border: 1px solid #bbb;
background-color: #fff;
box-shadow: none;
outline: none;
-webkit-appearance: none;
-moz-appearance: none; }
trix-toolbar .trix-input--dialog.validate:invalid {
box-shadow: #F00 0px 0px 1.5px 1px; }
trix-toolbar .trix-button--dialog {
font-size: inherit;
padding: 0.5em;
border-bottom: none; }
trix-toolbar .trix-dialog--link {
max-width: 600px; }
trix-toolbar .trix-dialog__link-fields {
display: flex;
align-items: baseline; }
trix-toolbar .trix-dialog__link-fields .trix-input {
flex: 1; }
trix-toolbar .trix-dialog__link-fields .trix-button-group {
flex: 0 0 content;
margin: 0; }
trix-editor [data-trix-mutable]:not(.attachment__caption-editor) {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none; }
trix-editor [data-trix-mutable]::-moz-selection,
trix-editor [data-trix-cursor-target]::-moz-selection, trix-editor [data-trix-mutable] ::-moz-selection {
background: none; }
trix-editor [data-trix-mutable]::selection,
trix-editor [data-trix-cursor-target]::selection, trix-editor [data-trix-mutable] ::selection {
background: none; }
trix-editor .attachment__caption-editor:focus[data-trix-mutable]::-moz-selection {
background: highlight; }
trix-editor .attachment__caption-editor:focus[data-trix-mutable]::selection {
background: highlight; }
trix-editor [data-trix-mutable].attachment.attachment--file {
box-shadow: 0 0 0 2px highlight;
border-color: transparent; }
trix-editor [data-trix-mutable].attachment img {
box-shadow: 0 0 0 2px highlight; }
trix-editor .attachment {
position: relative; }
trix-editor .attachment:hover {
cursor: default; }
trix-editor .attachment--preview .attachment__caption:hover {
cursor: text; }
trix-editor .attachment__progress {
position: absolute;
z-index: 1;
height: 20px;
top: calc(50% - 10px);
left: 5%;
width: 90%;
opacity: 0.9;
transition: opacity 200ms ease-in; }
trix-editor .attachment__progress[value="100"] {
opacity: 0; }
trix-editor .attachment__caption-editor {
display: inline-block;
width: 100%;
margin: 0;
padding: 0;
font-size: inherit;
font-family: inherit;
line-height: inherit;
color: inherit;
text-align: center;
vertical-align: top;
border: none;
outline: none;
-webkit-appearance: none;
-moz-appearance: none; }
trix-editor .attachment__toolbar {
position: absolute;
z-index: 1;
top: -0.9em;
left: 0;
width: 100%;
text-align: center; }
trix-editor .trix-button-group {
display: inline-flex; }
trix-editor .trix-button {
position: relative;
float: left;
color: #666;
white-space: nowrap;
font-size: 80%;
padding: 0 0.8em;
margin: 0;
outline: none;
border: none;
border-radius: 0;
background: transparent; }
trix-editor .trix-button:not(:first-child) {
border-left: 1px solid #ccc; }
trix-editor .trix-button.trix-active {
background: #cbeefa; }
trix-editor .trix-button:not(:disabled) {
cursor: pointer; }
trix-editor .trix-button--remove {
text-indent: -9999px;
display: inline-block;
padding: 0;
outline: none;
width: 1.8em;
height: 1.8em;
line-height: 1.8em;
border-radius: 50%;
background-color: #fff;
border: 2px solid highlight;
box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.25); }
trix-editor .trix-button--remove::before {
display: inline-block;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0.7;
content: "";
background-image: url("data:image/svg+xml,%3Csvg%20height%3D%2224%22%20width%3D%2224%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M19%206.41%2017.59%205%2012%2010.59%206.41%205%205%206.41%2010.59%2012%205%2017.59%206.41%2019%2012%2013.41%2017.59%2019%2019%2017.59%2013.41%2012z%22%2F%3E%3Cpath%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E");
background-position: center;
background-repeat: no-repeat;
background-size: 90%; }
trix-editor .trix-button--remove:hover {
border-color: #333; }
trix-editor .trix-button--remove:hover::before {
opacity: 1; }
trix-editor .attachment__metadata-container {
position: relative; }
trix-editor .attachment__metadata {
position: absolute;
left: 50%;
top: 2em;
transform: translate(-50%, 0);
max-width: 90%;
padding: 0.1em 0.6em;
font-size: 0.8em;
color: #fff;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 3px; }
trix-editor .attachment__metadata .attachment__name {
display: inline-block;
max-width: 100%;
vertical-align: bottom;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; }
trix-editor .attachment__metadata .attachment__size {
margin-left: 0.2em;
white-space: nowrap; }
.trix-content {
line-height: 1.5; }
.trix-content * {
box-sizing: border-box;
margin: 0;
padding: 0; }
.trix-content h1 {
font-size: 1.2em;
line-height: 1.2; }
.trix-content blockquote {
border: 0 solid #ccc;
border-left-width: 0.3em;
margin-left: 0.3em;
padding-left: 0.6em; }
.trix-content [dir=rtl] blockquote,
.trix-content blockquote[dir=rtl] {
border-width: 0;
border-right-width: 0.3em;
margin-right: 0.3em;
padding-right: 0.6em; }
.trix-content li {
margin-left: 1em; }
.trix-content [dir=rtl] li {
margin-right: 1em; }
.trix-content pre {
display: inline-block;
width: 100%;
vertical-align: top;
font-family: monospace;
font-size: 0.9em;
padding: 0.5em;
white-space: pre;
background-color: #eee;
overflow-x: auto; }
.trix-content img {
max-width: 100%;
height: auto; }
.trix-content .attachment {
display: inline-block;
position: relative;
max-width: 100%; }
.trix-content .attachment a {
color: inherit;
text-decoration: none; }
.trix-content .attachment a:hover, .trix-content .attachment a:visited:hover {
color: inherit; }
.trix-content .attachment__caption {
text-align: center; }
.trix-content .attachment__caption .attachment__name + .attachment__size::before {
content: ' \2022 '; }
.trix-content .attachment--preview {
width: 100%;
text-align: center; }
.trix-content .attachment--preview .attachment__caption {
color: #666;
font-size: 0.9em;
line-height: 1.2; }
.trix-content .attachment--file {
color: #333;
line-height: 1;
margin: 0 2px 2px 2px;
padding: 0.4em 1em;
border: 1px solid #bbb;
border-radius: 5px; }
.trix-content .attachment-gallery {
display: flex;
flex-wrap: wrap;
position: relative; }
.trix-content .attachment-gallery .attachment {
flex: 1 0 33%;
padding: 0 0.5em;
max-width: 33%; }
.trix-content .attachment-gallery.attachment-gallery--2 .attachment, .trix-content .attachment-gallery.attachment-gallery--4 .attachment {
flex-basis: 50%;
max-width: 50%; }

+ 81
- 0
app/controllers/admin/admin_controller.rb View File

@ -0,0 +1,81 @@
class Admin::AdminController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :only_admin!
helper_method :current_user
helper_method :user_signed_in?
helper_method :form_locale
private
def default_url_options
{ locale: I18n.locale }
end
def form_locale
(params['form_locale'] || I18n.locale).to_sym
end
def authenticate_user!
unless user_signed_in?
store_location
redirect_to admin_sessions_path
end
end
def only_admin!
unless current_user&.admin_role?
redirect_to root_path
end
end
def current_user
Current.user ||= authenticate_user_from_session
end
def authenticate_user_from_session
User.enabled.find_by(id: session[:user_id])
end
def user_signed_in?
current_user.present?
end
def login(user)
Current.user = user
reset_session
user.touch(:last_logon_at)
session[:user_id] = user.id
end
def logout(user)
Current.user = nil
reset_session
end
def redirect_back_or_default(default)
redirect_to(session[:return_to] || default)
session[:return_to] = nil
end
def store_location
session[:return_to] = request.fullpath if request.get?
end
end

+ 82
- 0
app/controllers/admin/assets_controller.rb View File

@ -0,0 +1,82 @@
class Admin::AssetsController < Admin::AdminController
before_action :set_asset, only: %i[ edit update destroy ]
helper_method :sorting_technique
# GET /assets
def index
@assets = Asset.public_send(sorting_technique, params[:reverse].present?)
.simple_search(params[:q])
.page(params[:page] || 1)
render (params[:node_id] || params[:newsletter_id]) ? 'explore' : 'index'
end
# GET /assets/1/edit
def edit
end
# PATCH/PUT /assets/1 or /assets/1.json
def update
respond_to do |format|
if @asset.update(asset_params)
format.html { redirect_to edit_admin_asset_url(@asset), notice: t(:'assets.updated') }
else
format.html { render :edit, status: :unprocessable_entity }
end
end
end
def upload
blob = ActiveStorage::Blob.find_signed(params[:signed_id])
@asset = Asset.create(file: blob, title: blob.filename.base.gsub("_", " "))
respond_to do |format|
format.turbo_stream
end
end
# DELETE /assets/1 or /assets/1.json
def destroy
@asset.destroy!
respond_to do |format|
format.html { redirect_to url_for(action: :index, format: :html), notice: t('assets.destroyed') }
unless params[:redirect_to]
format.turbo_stream { flash.now[:notice] = t('assets.destroyed') }
end
end
end
private
def sorting_technique
params[:sort].presence_in(%w(by_filename by_last_modified)) || :by_filename
end
# Use callbacks to share common setup or constraints between actions.
def set_asset
@asset = Asset.find(params[:id])
end
# Only allow a list of trusted parameters through.
def asset_params
params.require(:asset).permit(
:title
)
end
end

+ 29
- 0
app/controllers/admin/attachments_controller.rb View File

@ -0,0 +1,29 @@
class Admin::AttachmentsController < Admin::AdminController
before_action :set_attachable_for, only: %i[ new ]
def new
@attachments = []
if params[:asset_ids]
Asset.where(id: params[:asset_ids].split(',')).each do |asset|
@attachments << @attachable_for.attachments.new(asset: asset)
end
else
@attachments << @attachable_for.attachments.new
end
end
private
def set_attachable_for
@attachable_for = Node.find(params[:node_id]) if params[:node_id]
@attachable_for = Newsletter.find(params[:newsletter_id]) if params[:newsletter_id]
end
end

+ 188
- 0
app/controllers/admin/nodes_controller.rb View File

@ -0,0 +1,188 @@
class Admin::NodesController < Admin::AdminController
before_action :set_node, only: %i[ edit update destroy children sort toggle ]
helper_method :current_node_id, :open_node_ids
# GET /nodes
def index
# Can be nil
@node = Node.find_by(id: current_node_id) || Node.roots.first
end
# GET /nodes/1/edit
def edit
respond_to do |format|
format.turbo_stream
format.html
end
end
# GET /nodes/tree /nodes/1/tree
def tree
@node = params[:id] ? Node.find(params[:id]) : Node.roots.first
session[:current_node_id] = @node&.id
respond_to do |format|
format.turbo_stream
end
end
# POST /nodes or /nodes.json
def create
@node = Node.new(node_params)
@node.status = :status_draft
@node.template = @node.root.site? ? Node::NODE_TEMPLATES.first : Node::TILE_TEMPLATES.first
base_title = t('ui.untitled')
title = base_title
count = 0
while @node.siblings.exists?(title: {I18n.locale => title})
count += 1
title = "#{base_title} #{count}"
end
@node.title = title
respond_to do |format|
if @node.save
session[:open_node_ids] << @node.id unless session[:open_node_ids].include?(@node.id)
format.turbo_stream
format.html { redirect_to edit_admin_node_url(@node), notice: t('ui.category_created', category: t(@node.template, scope: 'nodes.templates')) }
else
format.html { render :new, status: :unprocessable_entity }
end
end
end
# GET /nodes/1/children
def children
store_node_toggle_status
respond_to do |format|
format.turbo_stream
end
end
# PATCH/PUT /nodes/1
def update
params[:node][:expires_at] ||= nil
respond_to do |format|
if @node.update(node_params)
format.turbo_stream {
flash.now[:notice] = t('ui.category_updated', category: t(@node.template, scope: 'nodes.templates'))
}
format.html { redirect_to edit_node_url(@node), notice: t('ui.category_updated', category: t(@node.category, scope: 'nodes.categories')) }
else
format.html { render :edit, status: :unprocessable_entity }
end
end
end
# DELETE /nodes/1
def destroy
if @node.destroy!
@destroyed_node = @node
@node = @node.parent
end
respond_to do |format|
format.turbo_stream {
flash.now[:notice] = t('ui.category_destroyed', category: t(@destroyed_node.category, scope: 'nodes.categories'))
}
format.html { redirect_to nodes_url, notice: t(:'nodes.destroyed') }
end
end
# PATCH /nodes/sort?id=
# {"id"=>"2", "old_index"=>1, "new_index"=>4, "node"=>{"id"=>"2"}}
def sort
@node.insert_at(params[:new_index].to_i + 1)
respond_to do |format|
format.turbo_stream
end
end
# Expand or collapse node in drawer
# PATCH /nodes/toggle?id=1&expanded=true/false
def toggle
store_node_toggle_status
head :ok
end
private
# Use callbacks to share common setup or constraints between actions.
def set_node
@node = Node.find(params[:id])
end
# Only allow a list of trusted parameters through.
def node_params
params.require(:node).permit(
:parent_id,
:title_da, :title_en, :title_de,
:page_title_da, :page_title_en, :page_title_de,
:page_description_da, :page_description_en, :page_description_de,
:slug_da, :slug_en, :slug_de,
:template,
:href_da, :href_en, :href_de,
:status,
:page_description,
:parent_id,
:position,
:published_at,
:expires_at,
:is_allowlist,
excluded_locales: [],
settings: [],
attachments_attributes: [:id, :asset_id, :body_da, :body_en, :body_de, :fg_color, :bg_color, :alignment, :template, :position, :_destroy]
)
end
def current_node_id
session[:current_node_id]
end
def open_node_ids
Array(session[:open_node_ids])
end
def ensure_open_node_ids
session[:open_node_ids] ||= []
end
def store_node_toggle_status
ensure_open_node_ids
if params[:expanded] == false
session[:open_node_ids].delete(@node.id)
else
session[:open_node_ids] << @node.id unless session[:open_node_ids].include?(@node.id)
end
end
end

+ 56
- 0
app/controllers/admin/sessions_controller.rb View File

@ -0,0 +1,56 @@
class Admin::SessionsController < Admin::AdminController
layout 'sessions'
skip_before_action :authenticate_user!, except: %i[destroy]
skip_before_action :only_admin!
def index
render action: 'new'
end
def create
if user = User.enabled.authenticate_by(params.permit(:email, :password))
# login user
# redirect_back_or_default(admin_root_path(locale: I18n.default_locale))
session[:verify_user_id] = user.id
UserMailer.with(user: user, verification_code: user.verification_codes.create).verify_email.deliver_later
redirect_to action: 'verification', locale: nil
else
flash.now.alert = t :'sessions.login_failed'
render action: 'new', status: :unprocessable_entity
end
end
def destroy
logout current_user
redirect_to root_path
end
# GET
def verification
end
# POST
def verify
if params[:verification_code] =~ /\A\d{6}\z/ and
user = User.enabled.find(session[:verify_user_id]) and
user.verification_codes.valid.find_by(token: params[:verification_code])
login user
redirect_back_or_default(admin_root_path(locale: I18n.default_locale))
else
flash.now.alert = t :'sessions.verification_failed'
render "verification"
end
end
end

+ 91
- 0
app/controllers/admin/users_controller.rb View File

@ -0,0 +1,91 @@
class Admin::UsersController < Admin::AdminController
before_action :set_user, only: %i[ edit update destroy ]
helper_method :sorting_technique
# GET /users
def index
@users = User.public_send(sorting_technique, params[:reverse].present?)
.simple_search(params[:q])
.page(params[:page] || 1)
end
# GET /users/new
def new
@user = User.new
end
# GET /users/1/edit
def edit
end
# POST /users or /users.json
def create
@user = User.new(user_params)
respond_to do |format|
if @user.save
format.html { redirect_to edit_admin_user_url(@user), notice: t(:'users.created') }
else
format.html { render :new, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /users/1 or /users/1.json
def update
respond_to do |format|
if @user.update(user_params)
format.html { redirect_to edit_admin_user_url(@user), notice: t(:'users.updated') }
else
format.html { render :edit, status: :unprocessable_entity }
end
end
end
# DELETE /users/1 or /users/1.json
def destroy
@user.destroy!
respond_to do |format|
format.html { redirect_to url_for(action: :index), notice: t(:'users.destroyed') }
end
end
private
def sorting_technique
params[:sort].presence_in(%w(by_name by_email by_last_modified)) || :by_name
end
# Use callbacks to share common setup or constraints between actions.
def set_user
@user = User.find(params[:id])
end
# Only allow a list of trusted parameters through.
def user_params
params.require(:user).permit(
:enabled_at,
:title,
:role,
:phone,
:firstname,
:lastname,
:email,
:password,
:password_confirmation
)
end
end

+ 41
- 0
app/helpers/admin/admin_helper.rb View File

@ -0,0 +1,41 @@
module Admin::AdminHelper
def entries_info(collection)
n = collection.respond_to?(:total_count) ? collection.total_count : collection.size
tag.div class: 'entries-info', id: 'n-entries' do
return "#{n} #{collection.model_name.human(count: n)}" if collection.respond_to?(:model_name)
return "#{n} #{collection.first.model_name.human(count: n)}" if collection.is_a?(Array) and collection.any?
end
end
def audit_info(obj)
tag.div do
concat tag.div(l(obj.updated_at, format: :listing), title: l(obj.updated_at, format: :long), class: 'audit-date')
concat tag.div(obj.own_and_associated_audits&.first&.user&.name, class: 'audit-info') if obj.respond_to?(:audits)
end
end
def link_to_sortable(sort_by, title=nil, options={})
title ||= sort_by.titleize
current = (sort_by.to_sym == sorting_technique.to_sym)
reverse = (params[:reverse].blank? and current) ? 1 : nil
css_class = ['sort_link']
css_class << 'current' if current
css_class << (reverse.blank? ? 'asc' : 'desc')
link_to title,
url_for(
params.permit(:q, :category, :tag).merge(sort: sort_by, reverse: reverse)
),
data: options[:data] || {
turbo_action: 'advance'
} ,
class: css_class.join(' ')
end
end

+ 2
- 0
app/helpers/admin/assets_helper.rb View File

@ -0,0 +1,2 @@
module Admin::AssetsHelper
end

+ 2
- 0
app/helpers/admin/attachments_helper.rb View File

@ -0,0 +1,2 @@
module Admin::AttachmentsHelper
end

+ 145
- 0
app/helpers/admin/nodes_helper.rb View File

@ -0,0 +1,145 @@
module Admin::NodesHelper
def node_structure_for_select(parent, node)
result = []
result << [parent.title, parent.id] if parent.root?
parent.children.ordered.each do |child|
result << ["#{"\u00A0\u00A0" * (child.depth)}#{child.title}".html_safe, child.id, { disabled: (child == node or child.ancestor_ids.include?(node.id)) }]
result = result + node_structure_for_select(child, node)
end
result
end
def spacer_node(node)
return if node.root?
tag.div class: 'spacer' do
node.depth.times do
concat tag.div nil
end
end
end
def toggle_node(node)
return tag.div nil, class: 'child' if node.document?
link_to url_for(controller: 'nodes', action: 'children', id: node.id),
class: 'child parent',
data: {
turbo_stream: true,
controller: "nodes",
action: "click->nodes#toggle_children"
} do
concat tag.span('expand_more')
concat tag.span('keyboard_arrow_right')
end
end
def tree_title(node)
# result = [link_to(ENV['PROJECT_NAME'],
# url_for(url_base_options.merge(id: nil)),
# class: "list-title-link#{' has-popup-menu' if node.blank?}",
# data: {
# icon: 'museum',
# action: node.blank? ? 'click->popup#toggle' : 'click->nodes#set_current',
# turbo_stream: node.blank? ? nil : true,
# })]
result = []
node&.path&.each do |n|
result << tree_node_title_link(n, node)
end
result[-1] = "#{tag.div "#{result[-1]}#{node_popup_menu(node)}".html_safe, data: {controller: 'popup'} }"
result.join(tag.span('>')).html_safe
end
def node_popup_menu(node=nil)
tag.div class: 'popup-menu' do
tag.ul do
concat tag.li(link_to t('ui.edit'), edit_admin_node_path(node), data: {icon: 'edit', turbo_action: 'advance'}) if node
Node.categories.each do |node_category|
concat tag.li button_to(t(node_category, scope: 'nodes.new_categories'),
url_for(controller: 'nodes', action: 'create'),
params: {node: {parent_id: node&.id}},
data: {
action: 'click->popup#close_open',
icon: t(node_category, scope: 'nodes.icons')
})
end
# concat tag.li button_to(t('ui.reindex'),
# url_for(controller: 'nodes', action: 'reindex', id: node.id),
# method: :patch,
# data: {
# action: 'click->popup#close_open',
# icon: 'refresh'
# }) if node
end
end
end
def node_link_title(node)
# return node.title if !node.document? or node.attachments.blank? or !node.attachments.first.asset.representable?
# asset = node.attachments.first.asset
# node_icon = image_tag(rails_storage_proxy_path(asset.representation(resize_to_limit: [48,72], format: :jpg))) if asset.representable?
node_icon = nil
node_title = tag.span(node.title)
(node_icon || '').concat(node_title).html_safe
end
def node_flags(node)
return if node.copyright_with_inheritance.blank? and node.tags_with_inheritance.blank?
tag.div class: 'node__flags' do
concat tag.span('copyright') if node.copyright_with_inheritance.present?
concat tag.span('sell') if node.tags_with_inheritance.any?
end
end
def drawer_node_link(node)
link_to tag.span(node.title),
url_for(controller: 'nodes', action: node.document? ? 'edit' : 'tree', id: node.id),
title: node.title,
class: 'node-title',
id: dom_id(node, 'drawer-link'),
data: {
nodes_id_param: node.id,
turbo_stream: true,
action: 'click->nodes#set_current',
icon: t("nodes.icons.#{node.category}")
}
end
def tree_node_title_link(node, current_node=nil)
current_node ||= node
url_base_options = {controller: 'nodes', action: 'tree'}
link_to(node.title,
url_for(url_base_options.merge(id: node.id)),
class: current_node == node ? 'list-title-link has-popup-menu' : 'list-title-link',
id: dom_id(node, 'list-title-link'),
data: {
action: current_node == node ? 'click->popup#toggle' : 'click->nodes#set_current',
turbo_stream: current_node != node ? true : nil,
nodes_id_param: node.id,
icon: t("nodes.icons.#{node.category}")
})
end
end

+ 2
- 0
app/helpers/admin/users_helper.rb View File

@ -0,0 +1,2 @@
module Admin::UsersHelper
end

+ 38
- 0
app/javascript/admin.js View File

@ -0,0 +1,38 @@
import "@hotwired/turbo-rails"
import "controllers"
import Trix from "trix"
// Bind ctrl + s to submit form
document.addEventListener('keydown', function (event) {
// Check if Ctrl key is pressed and the pressed key is 'S'
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
// Prevent the browser's default save action
event.preventDefault();
// Submit the form
const form = document.querySelector('.has--key-ctrls');
if (form) {
form.requestSubmit();
}
}
});
// Trix
Trix.config.blockAttributes.heading2 = { tagName: "h2", terminal: true, breakOnReturn: true, group: false }
// Trix.config.blockAttributes.heading3 = { tagName: "h3", terminal: true, breakOnReturn: true, group: false }
document.addEventListener("trix-initialize", event => {
var buttonHTML
buttonHTML = '<button type="button" class="trix-button trix-button--icon trix-button--icon-heading-2" data-trix-attribute="heading2">Heading</button>';
const groupElement = event.target.toolbarElement.querySelector(".trix-button-group--block-tools .trix-button--icon-heading-1")
groupElement.insertAdjacentHTML("afterend", buttonHTML)
const { toolbarElement } = event.target
const inputElement = toolbarElement.querySelector("input[name=href]")
inputElement.type = "text"
inputElement.pattern = "(https?://|/).+"
})

+ 34
- 0
app/javascript/controllers/assets_controller.js View File

@ -0,0 +1,34 @@
import { Controller } from "@hotwired/stimulus"
import { get } from '@rails/request.js'
export default class extends Controller {
static values = {
url: String
}
connect () {
this.fetchingData = false
}
appendAttachments(event) {
if (this.fetchingData) return
const checkedCheckboxes = this.element.querySelectorAll('input[name="asset_ids[]"]:checked')
const checkedValues = Array.from(checkedCheckboxes).map(checkbox => checkbox.value)
this.getAttachments(checkedValues)
this.dispatch("click", { target: document.getElementById('overlay'), prefix: null})
}
async getAttachments(ids) {
this.fetchingData = true
await get(this.urlValue, { query: {asset_ids: ids.join(',')}, responseKind: "turbo-stream" } )
this.fetchingData = false
}
}

+ 15
- 0
app/javascript/controllers/fields_controller.js View File

@ -0,0 +1,15 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
}
removeField(event) {
const input = this.element.querySelector('.destroy__field')
input.value = '1'
this.element.style = 'display: none';
}
}

+ 0
- 7
app/javascript/controllers/hello_controller.js View File

@ -1,7 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}

+ 26
- 0
app/javascript/controllers/i18n_form_controller.js View File

@ -0,0 +1,26 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.input = document.createElement('input');
this.input.type = 'hidden';
this.input.name = '_locale'; // Set the name for the input
this.input.value = this.element.getAttribute('data-locale'); // Set the value for the input
this.element.appendChild(this.input);
}
setFormLocale(event) {
const locale = event.target.getAttribute('value')
this.input.value = locale
this.element.setAttribute('data-locale', locale)
}
}

+ 27
- 0
app/javascript/controllers/load_more_controller.js View File

@ -0,0 +1,27 @@
import { Controller } from "@hotwired/stimulus"
import { useIntersection } from "stimulus-use"
import { get } from "@rails/request.js"
export default class extends Controller {
static values = {
url: String
}
connect() {
this.fetchingData = false
useIntersection(this)
}
async appear () {
if (this.fetchingData) return
this.fetchingData = true
await get(this.urlValue, { responseKind: "turbo-stream" } )
this.fetchingData = false
}
}

+ 14
- 0
app/javascript/controllers/navbar_controller.js View File

@ -0,0 +1,14 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
set_current() {
document.querySelectorAll('#navbar .current').forEach((node) => {
node.classList.remove('current')
})
this.element.classList.add('current')
}
}

+ 59
- 0
app/javascript/controllers/nodes_controller.js View File

@ -0,0 +1,59 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.url = document.getElementById('drawer').getAttribute('data-url')
this.token = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
}
// Toggle node children in drawer
toggle_children(event) {
const node = this.element.closest('.node-row');
node.classList.toggle('closed');
// Already loaded
if (node.parentNode.classList.contains('loaded')) {
fetch(this.url, {
method: 'PATCH',
headers: {
'X-CSRF-Token': this.token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: node.parentNode.dataset.id,
expanded: !node.classList.contains('closed')
})
}).then((response) => {
// console.info('resolved', response)
}).catch((err) => {
console.info('rejected', err)
})
event.preventDefault();
} else {
node.parentNode.classList.add('loaded')
}
}
// Mark current node in drawer
set_current(event) {
// Remove old current
var node = document.querySelector("#drawer .current")
if (node) {
node.classList.remove('current');
}
// Add new current
node = document.getElementById(`node_${event.params['id']}`)
if (node) {
node.classList.add('current')
}
}
}

+ 82
- 0
app/javascript/controllers/popup_controller.js View File

@ -0,0 +1,82 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.style.position = 'relative'
this.boundClosePopupOnClickOutside = this.closePopupOnClickOutside.bind(this)
}
toggle(event) {
const margin = 12
const btn = event.currentTarget
const popup_menu = btn.nextElementSibling
popup_menu.classList.toggle('open')
btn.classList.toggle('open')
if (popup_menu.classList.contains('open')) {
const bound_rect = popup_menu.getBoundingClientRect()
// Open to the right
if (btn.classList.contains('open--right')) {
popup_menu.style.left = btn.offsetWidth + margin + 'px'
popup_menu.style.top = "0px"
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
// Overflow buttom
if (bound_rect.bottom > viewportHeight) {
popup_menu.style.bottom = "0px"
popup_menu.style.top = "auto"
}
} else {
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
// Overflow right
if (bound_rect.right > viewportWidth) {
popup_menu.style.left = '-' + (bound_rect.width - btn.offsetWidth) + 'px'
}
}
document.addEventListener('mousedown', this.boundClosePopupOnClickOutside)
} else {
popup_menu.removeAttribute('style')
}
event.preventDefault()
}
close_open(e) {
document.querySelectorAll('.popup-menu.open, .has-popup-menu.open').forEach((node) => {
node.classList.remove('open')
node.removeAttribute('style')
})
document.removeEventListener('mousedown', this.boundClosePopupOnClickOutside)
}
closePopupOnClickOutside(e) {
const popup_menu = e.target.closest('.popup-menu, .has-popup-menu');
if (popup_menu == null) {
document.querySelectorAll('.popup-menu.open, .has-popup-menu.open').forEach((node) => {
node.classList.remove('open')
node.removeAttribute('style')
})
document.removeEventListener('mousedown', this.boundClosePopupOnClickOutside)
}
}
}

+ 31
- 0
app/javascript/controllers/select_controller.js View File

@ -0,0 +1,31 @@
import { Controller } from "@hotwired/stimulus"
import TomSelect from "tom-select";
export default class extends Controller {
connect() {
this.initializeTomSelect();
}
// Triggered when the Stimulus controller is removed from the DOM.
disconnect() {
this.destroyTomSelect();
}
// Initialize the TomSelect dropdown with the desired configurations.
initializeTomSelect() {
// Return early if no element is associated with the controller.
if (!this.element) return;
this.select = new TomSelect(this.element, {
create: this.element.getAttribute('data-tags') == 'true'
});
}
destroyTomSelect() {
if (this.select) {
this.select.destroy();
}
}
}

+ 51
- 0
app/javascript/controllers/sort_controller.js View File

@ -0,0 +1,51 @@
import { Controller } from "@hotwired/stimulus"
import Sortable from 'sortablejs'
export default class extends Controller {
connect() {
this.sortable = new Sortable(this.element, {
onEnd: this.end.bind(this),
handle: '.handle'
})
}
disconnect() {
this.sortable.destroy()
}
end(event) {
// sortableUrl
if (this.element.dataset.sortableUrl != undefined) {
const item = this.element.children[event.newIndex]
const url = this.element.dataset.sortableUrl
const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
const formData = new FormData()
formData.append('id', item.dataset.id)
formData.append('new_index', event.newIndex)
// console.log("Move", event.oldIndex, "to", event.newIndex);
fetch(url, {
method: 'PATCH',
headers: {
'Accept': "text/vnd.turbo-stream.html",
'X-CSRF-Token': token
},
body: formData
})
.then (response => response.text())
.then(html => Turbo.renderStreamMessage(html))
.catch((err) => {
console.info('rejected', err)
})
} else {
this.element.querySelectorAll('input.position').forEach((input, index) => {
input.value = index + 1
})
}
}
}

+ 117
- 0
app/javascript/controllers/upload_controller.js View File

@ -0,0 +1,117 @@
import { Controller } from "@hotwired/stimulus"
import { DirectUpload } from "@rails/activestorage"
// Connects to data-controller="upload"
export default class extends Controller {
static values = {
url: String,
createUrl: String,
parentId: Number,
target: String
}
connect() {
// console.info(this.urlValue)
// console.info('URL', this.createUrlValue)
}
// This will be triggered when a file is selected or dropped
upload(event) {
event.preventDefault()
const container = document.getElementById(this.targetValue)
const files = event.target.files || event.dataTransfer.files;
Array.from(files).forEach((file, index) => {
const uid = 'new_' + Date.now() + '_' + index
const target_div = document.createElement('div')
target_div.setAttribute('id', uid)
if (this.targetValue == 'attachments') {
target_div.classList.add('attachment', 'loading')
target_div.innerHTML = '<span class="progress">0</span>'
} else if (this.targetValue == 'tree-nodes') {
target_div.classList.add('row')
target_div.innerHTML = '<div class="cell"><span class="node-link progress" data-icon="description">0</span></div>'
}
container.append(target_div)
new Uploader(this, file, uid).start()
});
}
// Drag over event handler
dragover(event) {
event.preventDefault();
this.element.classList.add('drag-over');
}
// Drag leave event handler
dragleave(event) {
event.preventDefault();
this.element.classList.remove('drag-over');
}
// Drop event handler
drop(event) {
event.preventDefault();
this.element.classList.remove('drag-over');
// Call the upload method and pass in the drop event
this.upload(event);
}
}
class Uploader {
constructor(source, file, uid) {
this.source = source
this.uid = uid
this.upload = new DirectUpload(file, source.urlValue, this)
}
start() {
this.upload.create((error, blob) => {
if (error) {
// Handle the error
} else {
const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
const formData = new FormData()
if (this.source.parentIdValue > 0) {
formData.append('id', this.source.parentIdValue)
}
formData.append('signed_id', blob.signed_id)
formData.append('uid', this.uid)
fetch(this.source.createUrlValue, {
method: 'POST',
headers: {
'Accept': "text/vnd.turbo-stream.html",
'X-CSRF-Token': token
},
body: formData
})
.then (response => response.text())
.then(html => Turbo.renderStreamMessage(html))
.catch((err) => {
console.info('rejected', err)
})
}
})
}
directUploadWillStoreFileWithXHR(request) {
request.upload.addEventListener("progress",
event => this.directUploadDidProgress(event))
}
directUploadDidProgress(event) {
const target = document.getElementById(this.uid).querySelector('.progress')
const loaded = Math.round((event.loaded / event.total) * 100)
target.innerHTML = loaded
}
}

+ 75
- 0
app/javascript/controllers/utils_controller.js View File

@ -0,0 +1,75 @@
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="removals"
export default class extends Controller {
static values = {
statusText: String
}
connect() {
// console.info(this.tagIdsValue)
// console.info(this.updateUrlValue)
}
replaceWithStatus(event) {
this.element.innerText = this.statusTextValue;
}
changeDataN() {
// console.info(this.element.selectedOptions[0])
const context = this.element.closest('.context-container')
context.setAttribute('data-n', this.element.selectedOptions[0].getAttribute('data-n'))
}
disableScroll(){
document.body.style.overflow = 'hidden'
}
enableScroll(){
document.body.style.overflow = 'auto'
}
toogleSearchFilters(event) {
document.getElementById('search-filters').classList.toggle('open')
}
setCurrent(event) {
this.element.querySelector('.current').classList.remove('current')
event.currentTarget.closest('.node-attachment').classList.add('current')
}
closeOverlay(event){
// console.log(event);
if (event.target == this.element || event.target.getAttribute('data-action') == 'click->utils#closeOverlay') {
this.closeOverlayAndEnableScroll()
}
}
closeOverlayAndEnableScroll() {
const overlay = document.getElementById('overlay')
if (overlay) {
this.enableScroll()
overlay.remove();
}
}
remove() {
this.element.remove()
}
toggleDisabled() {
const elements = this.element.querySelectorAll('select, input')
elements.forEach(child => {
child.disabled = !child.disabled;
})
}
}

+ 8
- 1
app/mailers/application_mailer.rb View File

@ -1,4 +1,11 @@
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
ADMIN = "Two-factor authentication <2fa@onc.dk>"
CLIENT = ADMIN
default from: ADMIN,
return_path: ADMIN
layout "mailer"
end

+ 12
- 0
app/mailers/user_mailer.rb View File

@ -0,0 +1,12 @@
class UserMailer < ApplicationMailer
def verify_email
@user = params[:user]
@verification_code = params[:verification_code]
mail to: email_address_with_name(@user.email, @user.name),
subject: t('mailers.verify_email_subject', token: @verification_code&.token)
end
end

+ 19
- 0
app/models/asset.rb View File

@ -0,0 +1,19 @@
class Asset < ApplicationRecord
has_one_attached :file
include PgSearch::Model
pg_search_scope :pg_search,
against: [:title],
associated_against: {
file_blob: [:filename, :content_type]
},
using: {
tsearch: { prefix: true }
}
scope :by_last_modified, ->(rev) { order(updated_at: rev ? :asc : :desc, id: rev ? :desc : :asc) }
scope :by_filename, ->(rev) { order(title: rev ? :desc : :asc) }
scope :simple_search, ->(q) { pg_search(q) unless q.blank? }
end

+ 49
- 0
app/models/attachment.rb View File

@ -0,0 +1,49 @@
class Attachment < ApplicationRecord
extend Mobility
translates :body, :url, locale_accessors: I18n.available_locales
ALIGNMENT_TILE = %w"N NE E SE S SW W NW"
ALIGNMENT_SITE = %w"E W"
TEMPLATE_SITE_ASSET = %w"Hero L"
belongs_to :attachable_for, polymorphic: true, optional: true
belongs_to :asset, optional: true
store_accessor :props, :fg_color, :bg_color, :alignment, :template
acts_as_list scope: [:attachable_for_id, :attachable_for_type]
scope :ordered, -> { order(position: :asc) }
scope :favorites, -> { where(is_favorite: true) }
scope :favorites_first, -> { reorder("(is_favorite = TRUE) DESC, position ASC") }
scope :portraits_first, -> { reorder(Arel.sql("(props ->> 'is_portrait' = '1') DESC, position ASC")) }
scope :not_portraits, -> { where("(props ->> 'is_portrait' IS NULL OR props ->> 'is_portrait' != '1')") }
def is_large?
self.is_large.to_i == 1
end
def is_portrait?
self.is_portrait.to_i == 1
end
def template_subclass
return 'na' if self.template.blank?
self.template.downcase
end
def alignment_subclass
return 'na' if self.alignment.blank?
self.alignment.downcase
end
end

+ 54
- 0
app/models/concerns/ancestry_with_sorted_url.rb View File

@ -0,0 +1,54 @@
module AncestryWithSortedUrl
extend ActiveSupport::Concern
included do
has_ancestry orphan_strategy: :restrict,
cache_depth: true
acts_as_list scope: [:ancestry]
before_validation :format_slug, :generate_url
# validates_presence_of :slug, :url, unless: Proc.new { |node| node.root? }
# validates_uniqueness_of :slug, scope: [:ancestry]
after_commit :update_decendensts_url_if_changed
end
private
def format_slug
I18n.available_locales.each do |l|
v = self.root? ? (I18n.default_locale == l ? '' : l.to_s) :
self.send("slug_#{l}").blank? ? (self.title(locale: l) || '').parameterize : self.send("slug_#{l}").parameterize
self.send(:slug=, v, locale: l)
end
end
def generate_url
I18n.available_locales.each do |l|
Mobility.with_locale(l) do
v = File.join(self.ancestors.map { |node| node.slug || '' }, self.slug || '')
v = File.join('', v) unless I18n.default_locale == l
self.send(:url=, v, locale: l)
end
end
end
def update_decendensts_url_if_changed
self.reload.descendants.find_each(&:save) if self.previous_changes[:slug].present? or self.previous_changes[:ancestry]
end
end

+ 37
- 0
app/models/concerns/has_attachments.rb View File

@ -0,0 +1,37 @@
module HasAttachments
extend ActiveSupport::Concern
included do
has_many :attachments, -> { order :position }, dependent: :destroy, as: :attachable_for, class_name: "Attachment"
has_many :assets, through: :attachments
has_many :assets,
through: :attachments,
source: :asset
#has_many :images,
# -> { with_attached_file.where('assets.content_type LIKE ?', "image/%") },
# through: :attachments,
# source: :asset
#
#has_many :images_favorites_first,
# -> { with_attached_file.where('assets.content_type LIKE ?', "image/%").reorder('attachments.is_favorite DESC', 'attachments.position ASC') },
# through: :attachments,
# source: :asset
accepts_nested_attributes_for :attachments, allow_destroy: true
end
def icon
self.assets.map{ |asset| return asset if asset.file and asset.file.content_type.starts_with?('image/') }
nil
end
end

+ 48
- 0
app/models/concerns/has_tags.rb View File

@ -0,0 +1,48 @@
module HasTags
extend ActiveSupport::Concern
included do
scope :tagged_with, ->(tag) { tagged_with_any(tag) }
scope :all_or_tagged_with, ->(tag) { tagged_with_any(tag) if tag }
scope :tagged_with_any, ->(tags) { where("tags -> '#{I18n.locale}' ?| ARRAY[:tags]", tags: tags) }
scope :tagged_with_all, ->(tags) { where("tags @> ?", {"#{I18n.locale}": [tags].flatten}.to_json) }
end
def descendant_tags
return self.class.tags unless self.respond_to?(:descendants)
result = {}
I18n.available_locales.each { |l| result[l] = [] }
self.descendants.viewable.pluck(:tags).flatten.map do |tags|
tags.each do |k,v|
result[k.to_sym] += v
end
end
result.each{ |k, v| result[k] = v.flatten.uniq.sort }
end
module ClassMethods
def tags
result = {}
I18n.available_locales.each { |l| result[l] = [] }
pluck(:tags).flatten.map do |tags|
tags.each do |k,v|
result[k.to_sym] += v
end
end
result.each{ |k, v| result[k] = v.flatten.uniq.sort }
end
end
end

+ 3
- 0
app/models/current.rb View File

@ -0,0 +1,3 @@
class Current < ActiveSupport::CurrentAttributes
attribute :user
end

+ 134
- 0
app/models/node.rb View File

@ -0,0 +1,134 @@
class Node < ApplicationRecord
MENUS = %i"main_menu sub_menu footer_node cta_link opening_hours negative_menu buy_ticket newsletter"
COOKIE_POLICY = :cookie_policy
SETTINGS = MENUS << COOKIE_POLICY
include AncestryWithSortedUrl
include HasAttachments
include HasTags
extend Mobility
translates :slug, locale_accessors: I18n.available_locales
translates :tags, locale_accessors: I18n.available_locales
translates :title,
:url,
:href,
:page_title,
:page_description,
fallbacks: { en: :da, de: :en },
locale_accessors: I18n.available_locales
NODE_TEMPLATES = %w"tmpl_article tmpl_index"
enum status: { status_published: 0, status_draft: 1, status_archived: 2 }
enum template: { tmpl_index: 0,
tmpl_article: 1
}
include PgSearch::Model
pg_search_scope :pg_search,
against: {title: 'A', url: 'B', page_title: 'A', page_description: 'B', href: 'B', slug: 'B' },
associated_against: {
attachments: [:body, :url]
}
before_validation :remove_empty_tags
validates_presence_of :title
validates :expires_at,
date: { after: :published_at },
allow_nil: true
scope :ordered, -> { order(position: :asc) }
scope :by_title, ->(rev) { order(Arel.sql(rev ? "title->>'#{I18n.locale.to_s}' DESC, id DESC": "title ->>'#{I18n.locale.to_s}' ASC, id ASC")) }
scope :by_status, ->(rev) { order(status: rev ? :desc : :asc, id: rev ? :desc : :asc) }
scope :by_slug, ->(rev) { order(ancestry: rev ? :desc : :asc, position: rev ? :desc : :asc, id: rev ? :desc : :asc) }
scope :by_last_modified, ->(rev) { order(updated_at: rev ? :asc : :desc, id: rev ? :desc : :asc) }
# scope :not_excluded, -> { where "NOT(? = ANY (excluded_locales))", I18n.locale.to_s }
scope :for_current_locale, -> { where("(cardinality(excluded_locales) = 0 AND is_allowlist = false) OR
(is_allowlist = true AND ? = ANY(excluded_locales)) OR
(is_allowlist = false AND NOT(? = ANY(excluded_locales)))",
I18n.locale.to_s, I18n.locale.to_s ) }
scope :viewable, -> { status_published.for_current_locale.where('published_at <= ? AND (expires_at IS NULL OR expires_at > ?)', Time.current, Time.current) }
scope :of_template, ->(tmpl) { where(template: Node.templates[tmpl.to_s]) }
scope :with_setting, ->(setting) { setting.kind_of?(Array) ? where("settings && ?", "{#{setting.join(',')}}") : where("settings @> ?", "{#{setting}}") }
scope :simple_search, ->(q) { pg_search(q) unless q.blank? }
scope :tiles, -> { where("ancestry LIKE '/?/%'", Node.where(position: 2, ancestry_depth: 0).first) }
def self.tags
result = {}
I18n.available_locales.each do |locale|
result[locale] = pluck(Arel.sql("tags->'#{locale}'")).flatten.compact.uniq.sort.reject{|v| v.blank?}
end
result
end
def self.categories
['document']
end
def category
self.children.any? ? 'folder' : 'document'
end
def document?
self.category == 'document'
end
def href_or_url
href.present?? href : url
end
def viewable?
Node.viewable.where(id: self.path_ids).count == self.path_ids.length
end
def index?
self.attachments.blank?
end
def site?
(self.root || self).position == 1
end
def tile?
(self.root || self).position == 2
end
def node_type
return "site" if self.site?
'tile'
end
private
def remove_empty_tags
%w"tags_da tags_en tags_de settings excluded_locales".map do |k|
self.send "#{k}=", self.send(k).reject { |v| v.blank? } unless self.send(k).blank?
end
end
end

+ 70
- 0
app/models/user.rb View File

@ -0,0 +1,70 @@
class User < ApplicationRecord
enum role: { user: 'user', admin: 'admin' }, _suffix: true
include PgSearch::Model
pg_search_scope :pg_search, against: [:lastname, :firstname, :email, :phone, :title],
using: {tsearch: {dictionary: "danish"}}
has_secure_password
has_many :verification_codes, dependent: :destroy
before_destroy :dont_destroy_admin
validates_presence_of :email
validates_presence_of :password, on: :create
validates_uniqueness_of :email
validates_format_of :email, with: URI::MailTo::EMAIL_REGEXP
normalizes :email, with: -> email { email.strip.downcase }
validate :cant_change_admin, on: :update
scope :enabled, -> { where.not enabled_at: nil }
scope :by_last_modified, ->(rev) { order(updated_at: rev ? :asc : :desc) }
scope :by_name, ->(rev) { order(lastname: rev ? :desc : :asc, firstname: rev ? :desc : :asc) }
scope :by_email, ->(rev) { order(email: rev ? :desc : :asc) }
scope :by_title, ->(rev) { order(title: rev ? :desc : :asc) }
scope :simple_search, ->(q) { pg_search(q) unless q.blank? }
def su?
email == 'mattias@oncotype.dk'
end
def name
return email if lastname.blank? and firstname.blank?
[firstname, lastname].select{ |v| !v.blank? }.join(' ')
end
def initials
name.split(' ').map{ |s| s[0] }.join('').mb_chars.upcase
end
def enabled?
!self.enabled_at.nil?
end
protected
#Prevent the user admins from beeing changed
def cant_change_admin
user = self.class.find(self.id)
errors.add(:email, I18n.t(:you_cant_change_the_email_on_this_user, scope: 'users')) if user.su? and self.email != user.email
errors.add(:email, I18n.t(:you_cant_change_this_on_this_user, scope: 'users')) if user.su? and !self.admin_role? and self.role_changed?
errors.add(:email, I18n.t(:you_cant_disable_this_user, scope: 'users')) if user.su? and self.enabled_at.nil?
end
# Prevents the super user admin to be removed"
def dont_destroy_admin
raise I18n.t(:cant_destroy_this_user, scope: 'users') if self.su?
end
end

+ 21
- 0
app/models/verification_code.rb View File

@ -0,0 +1,21 @@
class VerificationCode < ApplicationRecord
VALID_FOR = 15.minutes
before_validation(on: :create) do
self.token = rand(100000..999999).to_s
end
validates_presence_of :token
belongs_to :user
scope :valid, -> { where("verification_codes.created_at > ?", VALID_FOR.ago) }
scope :expired, -> { where("verification_codes.created_at <= ?", VALID_FOR.ago) }
def expires_at
self.created_at + VALID_FOR
end
end

+ 33
- 0
app/views/admin/assets/_asset.html.erb View File

@ -0,0 +1,33 @@
<%= tag.div class: 'asset', id: dom_id(asset) do %>
<div class="asset__thumbnail">
<div><%= image_tag rails_storage_proxy_path(asset.file.representation(resize_to_limit: [320,320], format: :jpg)) if asset.file.representable? %></div>
</div>
<%= tag.div asset.title, class: 'asset__title' %>
<%= tag.div [
asset.file.filename.extension_without_delimiter.upcase,
number_to_human_size(asset.file.byte_size).sub(/\s/, ''),
[asset.file.metadata.dig('width'), asset.file.metadata.dig('height')].compact.join('x')
].reject{ |v| v.blank? }.join(' '),
class: 'asset__mimetypes' %>
<%= label_tag nil, class: "icon-cb-round" do %>
<%= check_box_tag 'asset_ids[]', asset.id, false, id: nil%>
<span></span>
<%- end -%>
<div class="asset-ctrls">
<div>
<%= link_to 'edit', edit_admin_asset_path(asset), data: {turbo_frame: 'main', turbo_action: 'advance'}, title: t('ui.edit') %>
<%= link_to 'download', rails_blob_path(asset.file, disposition: "attachment"), title: t('ui.download') %>
</div>
<%= link_to 'delete_forever', admin_asset_path(asset), data: { turbo_confirm: t(:'ui.are_you_sure'), turbo_method: :delete }, title: t('ui.delete') %>
</div>
<% end %>

+ 14
- 0
app/views/admin/assets/_form.html.erb View File

@ -0,0 +1,14 @@
<%= form_with(model: [ :admin, asset ], class: 'form-plain') do |form| %>
<div class="form-section">
<%= render partial: 'material/text_field', locals: { f: form, attr: :title } %>
</div>
<div class="form-ctrls">
<%= link_to t(:'ui.cancel'), url_for(controller: 'assets', action: 'index'), data: {turbo_action: 'advance'} %>
<%= form.submit t(:'ui.save') %>
</div>
<% end %>

+ 51
- 0
app/views/admin/assets/_list.html.erb View File

@ -0,0 +1,51 @@
<div class="list-title">
<h1><%= link_to yield(:title), params.permit(:sort, :reverse), class: 'list-title-link' %></h1>
<div class="flex">
<%= render partial: 'material/search', locals: (params[:node_id] ? {turbo_stream: true} : nil) %>
<%= tag.form do %>
<%= tag.label class: "dropzone dropzone--small",
data: {
controller: 'upload',
upload_url_value: rails_direct_uploads_url,
upload_create_url_value: url_for(controller: 'assets', action: 'upload'),
upload_target_value: 'assets',
action: "dragover->upload#dragover dragleave->upload#dragleave drop->upload#drop"
} do %>
<span>arrow_upward</span>
<input type="file" multiple data-action="change->upload#upload">
<% end %>
<% end %>
</div>
</div>
<div class="list-container">
<nav class="assets-sort">
<%= render partial: 'sort' %>
<div id="entries_info">
<%= entries_info @assets %>
</div>
</nav>
<div class="list">
<div class="assets__container">
<div id="assets">
<%= render @assets %>
</div>
<%= tag.div nil,
id: 'load-more',
data: {
controller: "load-more",
load_more_list_id_value: "results",
load_more_url_value: path_to_next_page(@assets)
} unless @assets.blank? or @assets.last_page? %>
</div>
</div>
</div>

+ 12
- 0
app/views/admin/assets/_sort.html.erb View File

@ -0,0 +1,12 @@
<div data-controller="popup" id="asset__sort">
<button class="navbar-link has-popup-menu" data-action="click->popup#toggle">
<%= link_to_sortable sorting_technique, t(sorting_technique, scope: 'sort') %>
</button>
<div class="popup-menu">
<ul>
<li><%= link_to_sortable :by_filename, t('sort.by_filename'), data: { turbo_stream: true } %></li>
<li><%= link_to_sortable :by_last_modified, t('sort.by_last_modified'), data: { turbo_stream: true } %></li>
</ul>
</div>
</div>

+ 3
- 0
app/views/admin/assets/destroy.turbo_stream.erb View File

@ -0,0 +1,3 @@
<%= turbo_stream.remove @asset %>
<%= turbo_stream.append 'flash', partial: 'layouts/flash' %>

+ 25
- 0
app/views/admin/assets/edit.html.erb View File

@ -0,0 +1,25 @@
<%= content_for :title, @asset.title %>
<%= turbo_frame_tag 'main' do %>
<%= turbo_stream.append 'flash', partial: 'layouts/flash' %>
<div class="form-container">
<%= link_to t(:'assets.list'), url_for(controller: 'assets', action: 'index'), data: {turbo_action: 'advance'}, class: 'back-link' %>
<div class="form-header">
<h1><%= yield :title %></h1>
<%= button_to t(:'assets.destroy'),
url_for(action: 'show', id: @asset),
method: :delete,
params: {redirect_to: admin_assets_path},
data: { turbo_confirm: t(:'ui.are_you_sure') },
tabindex: '-1',
class: 'delete-link' %>
</div>
<%= render "form", asset: @asset %>
</div>
<% end %>

+ 57
- 0
app/views/admin/assets/explore.turbo_stream.erb View File

@ -0,0 +1,57 @@
<%= content_for :title, t(:'assets.title') %>
<% if request.query_string.blank? %>
<turbo-stream action="append" targets="body">
<template>
<div id="overlay" class="overlay" data-controller="utils assets" data-action="click->utils#closeOverlay" data-assets-url-value="<%= url_for(controller: 'attachments', action: 'new', node_id: params[:node_id] ) %>">
<div class="inner-window-container">
<%= button_tag 'close', class: 'close-overlay', data: { action: "click->utils#closeOverlay" } %>
<div id="inner-window">
<%= render partial: 'list', formats: :html %>
<div class="form-ctrls form-ctrls-overlay">
<%= button_tag t(:'ui.append'), data: { action: "click->assets#appendAttachments" } %>
</div>
</div>
</div>
</div>
</template>
</turbo-stream>
<% else %>
<turbo-stream action="<%= @assets.first_page? ? 'update' : 'append' %>" target="assets">
<template>
<%= render partial: 'asset',
collection: @assets,
formats: :html %>
</template>
</turbo-stream>
<turbo-stream action="replace" target="asset__sort">
<template>
<%= render partial: 'sort', formats: :html %>
</template>
</turbo-stream>
<turbo-stream action="replace" target="load-more">
<template>
<% if @assets.blank? or @assets.last_page? %>
<div id='load-more'></div>
<% else %>
<%= tag.div nil,
id: 'load-more',
data: {
controller: "load-more",
load_more_list_id_value: "results",
load_more_url_value: path_to_next_page(@assets)
} %>
<% end %>
</template>
</turbo-stream>
<% end %>

+ 9
- 0
app/views/admin/assets/index.html.erb View File

@ -0,0 +1,9 @@
<%= content_for :title, t(:'assets.title') %>
<%= turbo_frame_tag 'main' do %>
<%= turbo_stream.append 'flash', partial: 'layouts/flash' %>
<%= render partial: 'list' %>
<% end %>

+ 30
- 0
app/views/admin/assets/index.turbo_stream.erb View File

@ -0,0 +1,30 @@
<turbo-stream action="<%= @assets.first_page? ? 'update' : 'append' %>" target="assets">
<template>
<%= render partial: 'asset',
collection: @assets,
formats: :html %>
</template>
</turbo-stream>
<turbo-stream action="replace" target="asset__sort">
<template>
<%= render partial: 'sort', formats: :html %>
</template>
</turbo-stream>
<turbo-stream action="replace" target="load-more">
<template>
<% if @assets.blank? or @assets.last_page? %>
<div id='load-more'></div>
<% else %>
<%= tag.div nil,
id: 'load-more',
data: {
controller: "load-more",
load_more_list_id_value: "results",
load_more_url_value: path_to_next_page(@assets)
} %>
<% end %>
</template>
</turbo-stream>

+ 7
- 0
app/views/admin/assets/upload.turbo_stream.erb View File

@ -0,0 +1,7 @@
<turbo-stream action="prepend" targets="#assets">
<template>
<%= render partial: 'asset', object: @asset, formats: :html %>
</template>
</turbo-stream>

+ 14
- 0
app/views/admin/attachments/_asset.html.erb View File

@ -0,0 +1,14 @@
<div class="attachment__asset">
<div class="asset__thumbnail">
<div><%= image_tag rails_storage_proxy_path(asset.file.representation(resize_to_limit: [320,320], format: :jpg)) if asset.file.representable? %></div>
</div>
<%= tag.div asset.title, class: 'asset__title' %>
<%= tag.div [
asset.file.filename.extension_without_delimiter.upcase,
number_to_human_size(asset.file.byte_size).sub(/\s/, ''),
[asset.file.metadata.dig('width'), asset.file.metadata.dig('height')].compact.join('x')
].reject{ |v| v.blank? }.join(' '),
class: 'asset__mimetypes' %>
</div>

+ 43
- 0
app/views/admin/attachments/_attachment.html.erb View File

@ -0,0 +1,43 @@
<div class="attachment attachment-<%= f.object.attachable_for&.node_type %>" data-controller="fields">
<div class="attachment__content">
<%= render partial: 'admin/attachments/asset', collection: Array(f.object.asset) %>
<%= render partial: 'material/trix_field_i18n', locals: { f: f, attr: :body } %>
<div class="attachment__content-three">
<%= render partial: 'material/text_field', locals: { f: f, attr: :bg_color } %>
<%= render partial: 'material/text_field', locals: { f: f, attr: :fg_color } %>
<%= render partial: 'material/select_field',
locals: {
f: f,
attr: :alignment,
choices: (f.object.attachable_for&.site? ? Attachment::ALIGNMENT_SITE : Attachment::ALIGNMENT_TILE).map { |k,v| [t(k, scope: :'attachments.alignments'), k] },
include_blank: true
} %>
<%= render partial: 'material/select_field',
locals: {
f: f,
attr: :template,
choices: Array(Attachment::TEMPLATE_SITE_ASSET).map { |k,v| [t(k, scope: :'attachments.templates'), k] },
include_blank: true
} %>
</div>
</div>
<%= button_tag 'delete',
type: 'button',
data: { action: 'click->fields#removeField' },
class: 'destroy__field-btn' %>
<%= f.hidden_field :_destroy, class: 'destroy__field' %>
<%= f.hidden_field :position, class: 'position' %>
<%= f.hidden_field :asset_id %>
<div class="handle">more_vert</div>
</div>

+ 14
- 0
app/views/admin/attachments/new.turbo_stream.erb View File

@ -0,0 +1,14 @@
<turbo-stream action="append" targets="#attachments">
<template>
<% @attachments.each_with_index do |attachment, i| %>
<%= fields_for "#{@attachable_for.class.name.underscore.singularize}[attachments_attributes][#{ Time.now.to_i + i }]", attachment do |builder| %>
<%= render partial: 'admin/attachments/attachment', locals: {f: builder}, formats: :html %>
<% end %>
<% end %>
<%# fields model: @node do |form| %>
<%# form.fields_for :attachments, @attachments do |builder| %>
<%# render partial: 'admin/attachments/attachment', locals: {f: builder} %>
<%# end%>
<%# end %>
</template>
</turbo-stream>

+ 5
- 0
app/views/admin/nodes/_drawer.html.erb View File

@ -0,0 +1,5 @@
<div id="drawer" data-controller="nodes" data-url="<%= url_for(controller: 'nodes', action: 'toggle') %>">
<div id="drawer-structure">
<%= render Node.roots.ordered %>
</div>
</div>

+ 14
- 0
app/views/admin/nodes/_edit.html.erb View File

@ -0,0 +1,14 @@
<div class="form-container">
<div class="form-nav-links">
<%= link_to t(:'nodes.list'),
url_for(action: 'tree' , id: @node.parent&.id),
data: { turbo_stream: true },
class: 'back-link' %>
<%= button_to t(:'nodes.destroy'), url_for(action: 'show', id: @node), method: :delete, data: { turbo_confirm: t('ui.are_you_sure') }, tabindex: '-1', class: 'delete-link' %>
</div>
<%= render "form" , node: @node %>
</div>

+ 203
- 0
app/views/admin/nodes/_form.html.erb View File

@ -0,0 +1,203 @@
<%= form_with(model: [:admin, node],
class: 'form-plain has--key-ctrls',
id: 'node_form',
data: {
locale: form_locale,
controller: "i18n-form"
}) do |form| %>
<div class="form-header__titled">
<%= form.label :title, class: "title-box", data: { icon: t("nodes.icons.#{@node.category}") } do %>
<%= render partial: 'material/text_field_i18n_simple', locals: { f: form, attr: :title } %>
<% end %>
<div class="i18n__from-ctrls">
<%- I18n.available_locales.each do |locale| %>
<%= label_tag do %>
<%= radio_button_tag 'form_locale', locale, form_locale == locale, data: {action: 'change->i18n-form#setFormLocale' } %>
<%= tag.span locale %>
<% end %>
<% end %>
</div>
</div>
<div class="form-section" id="node-attachments">
<div class="field">
<div>
<label><%= t(:'activerecord.attributes.node.attachments') %></label>
<div class="button-container">
<%= link_to t('ui.append_text'),
url_for(controller: 'attachments', action: 'new', node_id: node.id),
class: 'btn',
data: { turbo_stream: true } %>
<%= link_to t('ui.append_asset'),
url_for(controller: 'assets', action: 'index', node_id: node.id),
class: 'btn',
data: {
controller: 'utils',
action: 'click->utils#disableScroll',
turbo_stream: true
} %>
</div>
</div>
<div id="attachments" data-controller="sort">
<%= form.fields_for :attachments do |builder| %>
<%= render partial: 'admin/attachments/attachment', locals: {f: builder} %>
<% end%>
</div>
</div>
</div>
<div class="form-section">
<%= render partial: 'material/select_field',
locals: {
f: form,
attr: :parent_id,
choices: (@node.root? ? [] : node_structure_for_select(@node.root, @node)),
selected: @node.parent_id,
include_blank: false
} unless form.object.root? %>
<%= render partial: 'material/select_field',
locals: {
f: form,
attr: :template,
include_blank: form.object.root?,
choices: Node.templates.slice(*(Node::NODE_TEMPLATES)).map { |k,v| [t(k, scope: :'nodes.templates'), k] }.sort } %>
<%= render partial: 'material/tom_select_field',
locals: {
f: form,
attr: :settings,
choices: Node::SETTINGS.map{ |tag| [ t(tag, scope: :'nodes.settings') , tag.to_s] }.sort,
multiple: true
} %>
<%= render partial: 'material/text_field_i18n', locals: { f: form, attr: :href } unless form.object.root? %>
<%= render partial: 'material/tom_select_field_i18n',
locals: {
f: form,
attr: :tags,
choices: Node.tags,
multiple: true,
tags: true
} unless form.object.root? %>
</div>
<div class="form-section">
<%= render partial: 'material/text_field_i18n', locals: { f: form, attr: :page_title } %>
<%= render partial: 'material/text_field_i18n', locals: { f: form, attr: :page_description } %>
<%= tag.div class: 'field' do %>
<div>
<%= form.label :slug, for: nil, class: 'i18n__label' %>
</div>
<div>
<%- I18n.available_locales.each do |locale| %>
<%- i18n_attr = "slug_#{locale}" -%>
<%= form.label i18n_attr, class: "input-box input-box-url i18n__input i18n__input-#{locale}" do %>
<%= tag.span File.join(form.object.parent&.url(locale: locale) || '', ''), class: 'base__url' %>
<%= form.text_field i18n_attr,
class: 'material__input',
disabled: form.object.root? %>
<% end %>
<%- form.object.errors.full_messages_for(i18n_attr).uniq.each do |msg| -%>
<%= content_tag :p, msg, role: 'alert' %>
<% end %>
<% end %>
</div>
<%- end -%>
</div>
<div class="form-section">
<div class="field">
<div>
<%= form.label :status, for: nil %>
</div>
<div>
<ul class="node__status">
<%- Node.statuses.each do |status| -%>
<li><%= form.radio_button :status, status[0], id: status[1] %> <%= label_tag status[1], t(status[0], scope: :'nodes.statuses'), class: 'plain' %></li>
<% end %>
</ul>
</div>
</div>
<div class="field">
<div><%= form.label :published_at %></div>
<div>
<div class="datetime__select">
<%= form.datetime_select :published_at,
{datetime_separator: '<span>@</span>',
time_separator: '<span>:</span>',
use_short_month: true}, {class: 'material__input material__input-select'} %>
</div>
<%- form.object.errors.full_messages_for(:published_at).uniq.each do |msg| -%>
<%= content_tag :p, msg, role: 'alert' %>
<% end %>
</div>
</div>
<div class="field">
<div><%= form.label :expires_at %></div>
<div>
<div data-controller="utils" class="datetime__select with-toggles">
<%= form.datetime_select :expires_at,
{datetime_separator: '<span>@</span>',
time_separator: '<span>:</span>',
use_short_month: true,
default: (1.month.from_now.at_midnight-1.minute),
disabled: (@node.expires_at.blank?)}, { class: 'material__input material__input-select' } %>
<%= button_tag 'add_alarm', type: 'button', data: { action: 'click->utils#toggleDisabled' } %>
<%= button_tag 'remove_circle_outline', type: 'button', data: { action: 'click->utils#toggleDisabled' } %>
</div>
<%- form.object.errors.full_messages_for(:expires_at).uniq.each do |msg| -%>
<%= content_tag :p, msg, role: 'alert' %>
<% end %>
</div>
</div>
<%= render partial: 'material/tom_select_field',
locals: {
f: form,
attr: :excluded_locales,
choices: options_for_select(I18n.available_locales.map{ |v| [t(v, scope: :'nodes.langs'), v.to_s] }, form.object.excluded_locales),
multiple: true
} %>
<%= render partial: 'material/check_box_icon', locals: { f: form, attr: :is_allowlist} %>
</div>
<div class="form-ctrls">
<%= link_to t(:'ui.cancel'),
url_for(action: 'tree', id: @node.parent&.id),
data: {
turbo_stream: true,
turbo_action: 'advance'
} %>
<%= form.submit t(:'ui.save') %>
</div>
<% end %>

+ 17
- 0
app/views/admin/nodes/_node.html.erb View File

@ -0,0 +1,17 @@
<%= tag.div class: "node#{' loaded' if open_node_ids.include?(node.id)}#{' current' if node.id == current_node_id}",
id: dom_id(node),
data: {
id: node.id
} do %>
<%= tag.div class: "node-row #{node.category}#{' closed' unless open_node_ids.include?(node.id)}",
style: "--level: #{node.depth}" do %>
<%= spacer_node node %>
<%= toggle_node node %>
<%= drawer_node_link node %>
<% end %>
<%= render node.children.ordered if open_node_ids.include?(node.id) %>
<% end %>

+ 24
- 0
app/views/admin/nodes/_tree.html.erb View File

@ -0,0 +1,24 @@
<div>
<div class="list-title">
<div class="node-path" data-controller="nodes">
<%= tree_title(@node) %>
</div>
</div>
<div class="list tree-children">
<div class="list-header">
<div class="cell"><%= t(:'activerecord.attributes.node.title') %></div>
<div class="cell flags"></div>
<div class="cell"><%= t(:'activerecord.attributes.node.occasions') %></div>
<div class="cell"><%= t(:'ui.updated') %></div>
<div class="cell actions"></div>
</div>
<div id="tree-nodes" data-controller="sort" data-sortable-url="<%= url_for(controller: 'nodes', action: 'sort') %>">
<%= render partial: 'tree_node', collection: @node ? @node.children.ordered : Node.roots.ordered, as: 'node' %>
</div>
</div>
</div>

+ 38
- 0
app/views/admin/nodes/_tree_node.html.erb View File

@ -0,0 +1,38 @@
<div class="row" data-id="<%= node.id %>">
<div class="cell" data-controller="nodes">
<%= link_to node_link_title(node),
url_for(action: 'tree', id: node),
class: 'node-link',
data: {
turbo_stream: true,
icon: t("nodes.icons.#{node.category}"),
nodes_id_param: node.id,
action: 'click->nodes#set_current'
} %>
</div>
<div class="cell flags">
</div>
<div class="cell date">
<%# node_flags(node) %>
<ul>
<% node.occasions.each do |occasion| %>
<%= tag.li occasion_date(occasion) %>
<% end %>
</ul>
</div>
<div class="cell">
<%= audit_info node %>
</div>
<div class="cell actions">
<%= link_to 'edit', edit_admin_node_path(node), class:"icon size--medium round", data: {turbo_action: 'advance'} %>
<%= tag.div 'more_vert', class: 'handle' %>
</div>
</div>

+ 8
- 0
app/views/admin/nodes/children.turbo_stream.erb View File

@ -0,0 +1,8 @@
<%= turbo_stream.append @node do %>
<% @node.children.ordered.each do |node| %>
<%= render node, closed: true %>
<% end %>
<% end %>

+ 27
- 0
app/views/admin/nodes/create.turbo_stream.erb View File

@ -0,0 +1,27 @@
<%= turbo_stream.append "tree-nodes" do %>
<%= render partial: 'tree_node', object: @node, as: 'node' %>
<% end %>
<% if @node.parent %>
<turbo-stream action="replace" targets="#<%= dom_id(@node.parent) %>">
<template>
<%= render partial: 'node', object: @node.parent %>
</template>
</turbo-stream>
<turbo-stream action="replace" targets="#<%= dom_id(@node.parent, 'list-title-link') %>">
<template><%= tree_node_title_link @node.parent %></template>
</turbo-stream>
<% else %>
<turbo-stream action="append" targets="#drawer-structure">
<template>
<%= render partial: 'node', object: @node %>
</template>
</turbo-stream>
<% end %>

+ 16
- 0
app/views/admin/nodes/destroy.turbo_stream.erb View File

@ -0,0 +1,16 @@
<%= turbo_stream.remove @destroyed_node %>
<% if @node %>
<turbo-stream action="replace" targets="#<%= dom_id(@node) %>">
<template>
<%= render partial: 'node', object: @node %>
</template>
</turbo-stream>
<% end %>
<%= turbo_stream.update "tree" do %>
<%= render partial: "tree" %>
<% end %>
<%= turbo_stream.append 'flash', partial: 'layouts/flash' %>

+ 13
- 0
app/views/admin/nodes/edit.html.erb View File

@ -0,0 +1,13 @@
<%= content_for :title, @node.title %>
<%= turbo_frame_tag 'main' do %>
<%= turbo_stream.append 'flash' , partial: 'layouts/flash' %>
<%= render partial: 'drawer' %>
<%= turbo_frame_tag 'tree' do %>
<%= render partial: 'edit' %>
<% end %>
<% end %>

+ 5
- 0
app/views/admin/nodes/edit.turbo_stream.erb View File

@ -0,0 +1,5 @@
<%= turbo_stream.update "tree" do %>
<%= render partial: "edit" %>
<% end %>

+ 15
- 0
app/views/admin/nodes/index.html.erb View File

@ -0,0 +1,15 @@
<%= content_for :title, t(:'nodes.title') %>
<%= turbo_frame_tag 'main' do %>
<%= turbo_stream.append 'flash', partial: 'layouts/flash' %>
<%= render partial: 'drawer' %>
<%= turbo_frame_tag 'tree' do %>
<%= render partial: 'tree' %>
<% end %>
<% end %>

+ 15
- 0
app/views/admin/nodes/sort.turbo_stream.erb View File

@ -0,0 +1,15 @@
<% if @node.root? %>
<turbo-stream action="update" target="drawer-structure">
<template>
<%= render partial: 'node', collection: Node.roots.ordered, formats: :html %>
</template>
</turbo-stream>
<% end %>
<% if @node.parent %>
<turbo-stream action="replace" targets="#<%= dom_id(@node.parent) %>.loaded">
<template>
<%= render partial: 'node', object: @node.parent, formats: :html %>
</template>
</turbo-stream>
<% end %>

+ 3
- 0
app/views/admin/nodes/tree.turbo_stream.erb View File

@ -0,0 +1,3 @@
<%= turbo_stream.update 'tree' do %>
<%= render partial: 'tree' %>
<% end %>

+ 10
- 0
app/views/admin/nodes/update.turbo_stream.erb View File

@ -0,0 +1,10 @@
<%= turbo_stream.replace "node_form" do %>
<%= render "form", node: @node %>
<% end %>
<turbo-stream action="replace" targets="#<%= dom_id(@node, 'drawer-link') %>">
<template><%= drawer_node_link @node %></template>
</turbo-stream>
<%= turbo_stream.append 'flash', partial: 'layouts/flash' %>

+ 29
- 0
app/views/admin/sessions/new.html.erb View File

@ -0,0 +1,29 @@
<%= link_to(svg('ikea-foundation'), root_path) %>
<%= form_tag url_for(action: 'index', locale: nil) do %>
<div>
<%= label_tag :email, t('sessions.email') %>
<%= label_tag :email, class: "input-box" do %>
<%= text_field_tag :email, params[:email] %>
<% end %>
</div>
<%= content_tag :div, class: flash.any? ? 'with-errors' : nil do %>
<%= label_tag :password, t('sessions.password') %>
<%= label_tag :password, class: "input-box" do %>
<%= password_field_tag :password %>
<% end %>
<%- flash.each do |name, msg| -%>
<%= content_tag :p, msg, role: 'alert' %>
<%- end -%>
<%- end -%>
<div>
<%= button_tag t(:'sessions.login') %>
</div>
<% end %>

+ 20
- 0
app/views/admin/sessions/verification.html.erb View File

@ -0,0 +1,20 @@
<%= link_to(svg('ikea-foundation'), root_path) %>
<%= form_tag url_for(action: 'verify', locale: nil) do %>
<%= content_tag :div, class: flash.any? ? 'with-errors' : nil do %>
<%= label_tag :verification_code, t('sessions.verification_code') %>
<%= label_tag :verification_code, class: "input-box" do %>
<%= text_field_tag :verification_code, params[:verification_code] %>
<% end %>
<%- flash.each do |name, msg| -%>
<%= content_tag :p, msg, role: 'alert' %>
<%- end -%>
<% end %>
<div>
<%= button_tag t(:'sessions.verify_email') %>
</div>
<% end %>

+ 27
- 0
app/views/admin/users/_form.html.erb View File

@ -0,0 +1,27 @@
<%= form_with(model: [ :admin, user ], class: 'form-plain has--key-ctrls' ) do |form| %>
<div class="form-section">
<%= render partial: 'material/check_box_icon', locals: { f: form, attr: :enabled_at, label: t(:'activerecord.attributes.user.enabled_at')} %>
<%= render partial: 'material/select_field', locals: { f: form, attr: :role, include_blank: false, choices: User.roles.map { |k,v| [t(k, scope: :'users.roles'), k] }.sort } %>
</div>
<div class="form-section">
<%= render partial: 'material/text_field', locals: { f: form, attr: :firstname } %>
<%= render partial: 'material/text_field', locals: { f: form, attr: :lastname } %>
<%= render partial: 'material/text_field', locals: { f: form, attr: :title } %>
<%= render partial: 'material/text_field', locals: { f: form, attr: :email } %>
<%= render partial: 'material/text_field', locals: { f: form, attr: :phone } %>
</div>
<div class="form-section">
<%= render partial: 'material/password_field', locals: { f: form, attr: :password } %>
<%= render partial: 'material/password_field', locals: { f: form, attr: :password_confirmation } %>
</div>
<div class="form-ctrls">
<%= link_to t(:'ui.cancel'), url_for(controller: 'users', action: 'index'), data: {turbo_action: 'advance'} %>
<%= form.submit t(:'ui.save') %>
</div>
<% end %>

+ 29
- 0
app/views/admin/users/_user.html.erb View File

@ -0,0 +1,29 @@
<div class="row" id="<%= dom_id user %>">
<div class="cell">
<ul>
<li><%= user.name %></li>
<li class="user__role"><%= t user.role, scope: 'users.roles' %></li>
</ul>
</div>
<div class="cell">
<%= user.email %>
</div>
<div class="cell">
<%= user.phone %>
</div>
<div class="cell">
<ul>
<li>
<% if user.enabled? %>
<span class="icon size--small enabled">check_circle</span> <span><%= t :'ui.active' %></span>
<% else %>
<span class="icon size--small disabled">cancel</span> <span><%= t :'ui.inactive' %></span>
<% end %>
</li>
<li><%= audit_info user %></li>
</ul>
</div>
<div class="cell actions">
<%= link_to 'edit', url_for(action: 'edit', id: user), class:"icon size--medium round", data: {turbo_action: 'advance'} %>
</div>
</div>

+ 19
- 0
app/views/admin/users/edit.html.erb View File

@ -0,0 +1,19 @@
<%= content_for :title, @user.name %>
<%= turbo_frame_tag 'main' do %>
<%= turbo_stream.append 'flash', partial: 'layouts/flash' %>
<div class="form-container">
<%= link_to t(:'users.list'), url_for(controller: 'users', action: 'index'), data: {turbo_action: 'advance'}, class: 'back-link' %>
<div class="form-header">
<h1><%= yield :title %></h1>
<%= button_to t(:'users.destroy'), url_for(action: 'show', id: @user), method: :delete, data: { turbo_confirm: t(:'ui.are_you_sure') }, tabindex: '-1', class: 'delete-link' %>
</div>
<%= render "form", user: @user %>
</div>
<% end %>

+ 41
- 0
app/views/admin/users/index.html.erb View File

@ -0,0 +1,41 @@
<%= content_for :title, t(:'users.title') %>
<%= turbo_frame_tag 'main' do %>
<%= turbo_stream.append 'flash', partial: 'layouts/flash' %>
<div class="list-title">
<h1><%= link_to yield(:title), params.permit(:sort, :reverse), class: 'list-title-link' %></h1>
<div class="flex">
<%= render 'material/search' %>
<%= link_to tag.span(t(:'ui.new')), url_for(action: 'new'), class: 'btn add-btn', data: {turbo_action: 'advance'} %>
</div>
</div>
<div class="list-container">
<div class="list" >
<div class="list-header">
<div class="cell"><%= link_to_sortable :by_name, t(:'activerecord.attributes.user.name') %></div>
<div class="cell"><%= link_to_sortable :by_email, t(:'activerecord.attributes.user.email') %></div>
<div class="cell"><%= t(:'activerecord.attributes.user.phone') %></div>
<div class="cell"><%= t(:'activerecord.attributes.user.status') %></div>
<div class="cell actions"></div>
</div>
<div id="users">
<%= render @users %>
</div>
<div class="pagination-container">
<div><%= page_entries_info @users %></div>
<%= paginate @users %>
</div>
</div>
</div>
<% end %>

+ 17
- 0
app/views/admin/users/new.html.erb View File

@ -0,0 +1,17 @@
<%= content_for :title, t(:'users.new') %>
<%= turbo_frame_tag 'main' do %>
<%= turbo_stream.append 'flash', partial: 'layouts/flash' %>
<div class="form-container">
<%= link_to t(:'users.list'), url_for(controller: 'users', action: 'index'), data: {turbo_action: 'advance'}, class: 'back-link' %>
<div class="form-header">
<h1><%= yield :title %></h1>
</div>
<%= render "form", user: @user %>
</div>
<% end %>

+ 11
- 0
app/views/kaminari/_first_page.html.erb View File

@ -0,0 +1,11 @@
<%# Link to the "First" page
- available local variables
url: url to the first page
current_page: a page object for the currently displayed page
total_pages: total number of pages
per_page: number of items to fetch per page
remote: data-remote
-%>
<span class="first">
<%= link_to_unless current_page.first?, t('views.pagination.first').html_safe, url, remote: remote %>
</span>

+ 8
- 0
app/views/kaminari/_gap.html.erb View File

@ -0,0 +1,8 @@
<%# Non-link tag that stands for skipped pages...
- available local variables
current_page: a page object for the currently displayed page
total_pages: total number of pages
per_page: number of items to fetch per page
remote: data-remote
-%>
<span class="page gap"><%= t('views.pagination.truncate').html_safe %></span>

+ 11
- 0
app/views/kaminari/_last_page.html.erb View File

@ -0,0 +1,11 @@
<%# Link to the "Last" page
- available local variables
url: url to the last page
current_page: a page object for the currently displayed page
total_pages: total number of pages
per_page: number of items to fetch per page
remote: data-remote
-%>
<span class="last">
<%= link_to_unless current_page.last?, t('views.pagination.last').html_safe, url, remote: remote %>
</span>

+ 11
- 0
app/views/kaminari/_next_page.html.erb View File

@ -0,0 +1,11 @@
<%# Link to the "Next" page
- available local variables
url: url to the next page
current_page: a page object for the currently displayed page
total_pages: total number of pages
per_page: number of items to fetch per page
remote: data-remote
-%>
<span class="next">
<%= link_to_unless current_page.last?, t('views.pagination.next').html_safe, url, rel: 'next', remote: remote %>
</span>

+ 12
- 0
app/views/kaminari/_page.html.erb View File

@ -0,0 +1,12 @@
<%# Link showing page number
- available local variables
page: a page object for "this" page
url: url to this page
current_page: a page object for the currently displayed page
total_pages: total number of pages
per_page: number of items to fetch per page
remote: data-remote
-%>
<span class="page<%= ' current' if page.current? %>">
<%= link_to_unless page.current?, page, url, {remote: remote, rel: page.rel} %>
</span>

+ 18
- 0
app/views/kaminari/_paginator.html.erb View File

@ -0,0 +1,18 @@
<%# The container tag
- available local variables
current_page: a page object for the currently displayed page
total_pages: total number of pages
per_page: number of items to fetch per page
remote: data-remote
paginator: the paginator that renders the pagination tags inside
-%>
<%= paginator.render do -%>
<nav class="pagination" role="navigation" aria-label="pager">
<%= first_page_tag %>
<%= prev_page_tag %>
<% unless current_page.out_of_range? %>
<%= next_page_tag %>
<%= last_page_tag %>
<% end %>
</nav>
<% end -%>

+ 11
- 0
app/views/kaminari/_prev_page.html.erb View File

@ -0,0 +1,11 @@
<%# Link to the "Previous" page
- available local variables
url: url to the previous page
current_page: a page object for the currently displayed page
total_pages: total number of pages
per_page: number of items to fetch per page
remote: data-remote
-%>
<span class="prev">
<%= link_to_unless current_page.first?, t('views.pagination.previous').html_safe, url, rel: 'prev', remote: remote %>
</span>

+ 5
- 0
app/views/layouts/_flash.html.erb View File

@ -0,0 +1,5 @@
<% flash.each do |flash_type, message| %>
<div class="flash__message" data-controller="utils" data-action="animationend->utils#remove">
<span class="icon">info</span></span><%= tag.span message %>
</div>
<% end %>

+ 70
- 0
app/views/layouts/admin.html.erb View File

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html>
<head>
<title><%= content_for?(:title) ? yield(:title) : ENV['PROJECT_NAME_LONG'] %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="turbo-prefetch" content="false">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "admin", "trix", "tom-select", "popup-menu", "forms", "lists", "assets", "nodes", "attachments" %>
<%= javascript_importmap_tags 'admin' %>
</head>
<body>
<nav id="navbar">
<div class="navbar-upper">
<% %i[nodes assets].each do |c| %>
<%= link_to url_for(controller: c, action: 'index'),
class: (controller_name == c.to_s ? 'navbar-link current' : 'navbar-link'),
data: {
controller: 'navbar',
action: 'click->navbar#set_current',
turbo_frame: 'main',
turbo_action: 'advance'
} do %>
<span class="icon"><%= t c, scope: 'icons' %></span>
<% end %>
<% end %>
</div>
<%= link_to svg('ikea-foundation-white'), root_url, class: 'logo', target: '_blank' %>
<div class="navbar-lower">
<% %i[users].each do |c| %>
<%= link_to url_for(controller: c, action: 'index'),
class: (controller_name == c.to_s ? 'navbar-link current' : 'navbar-link'),
data: {
controller: 'navbar',
action: 'click->navbar#set_current',
turbo_frame: 'main',
turbo_action: 'advance'
} do %>
<span class="icon"><%= t c, scope: 'icons' %></span>
<% end %>
<% end if current_user.admin_role? %>
<div data-controller="popup">
<button class="navbar-link has-popup-menu open--right" data-action="click->popup#toggle">
<span class="icon">settings</span>
</button>
<div class="popup-menu">
<ul>
<li><%= tag.div current_user.email %></li>
<li><%= link_to t('sessions.logout'), admin_logout_path, data: {icon: 'logout', turbo_frame: '_top'} %></li>
</ul>
</div>
</div>
</div>
</nav>
<%= yield %>
<div id="flash">
<%= render 'layouts/flash' %>
</div>
</body>
</html>

+ 120
- 12
app/views/layouts/mailer.html.erb View File

@ -1,13 +1,121 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0;
mso-table-rspace: 0
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: 0;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
font-family: system-ui, sans-serif;
font-size: 18px;
line-height: 26px;
font-weight: 400;
text-align: center;
margin: 0 0 0 0;
}
.booking p {
text-align: left;
}
h1 {
font-family: system-ui, sans-serif;
font-size: 32px;
line-height: 1.2;
margin: 0 0 0 0;
font-weight: bold;
}
h2 {
font-family: system-ui, sans-serif;
font-size: 24px;
line-height: 1.2;
margin: 0 0 0 0;
font-weight: bold;
}
h3 {
font-family: system-ui, sans-serif;
font-size: 18px;
line-height: 1.2;
margin: 1em 0 0 0;
font-weight: bold;
}
.align-left {
text-align: left;
}
a.btn {
font-family: system-ui, sans-serif;
color: #FFFFFF;
background-color: #FF4F32;
text-decoration: none;
text-transform: uppercase;
padding: 9px 40px 9px 40px;
}
.column-px-320 {
width: 320px !important;
max-width: 320px
}
</style>
</head>
<body>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="center" valign="top" style="padding: 40px 20px 0 20px;">
<table border="0" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 480px;">
<tbody>
<tr>
<td align='center' style="height: 58px; text-align: left; padding: 0 0 40px 0">
<%# link_to image_tag('den-hirschsprungske-samling.png', size: "180x58" , alt: I18n.t(:client_name)),
root_url, title: I18n.t(:client_name) %>
<h1>
Two-factor authentication
</h1>
</td>
</tr>
<%= yield %>
</tbody>
</table>
</td>
</tr>
</table>
</body>
</html>

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save