diff --git a/.gitignore b/.gitignore
index a5e0e2d..e17c36b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,8 @@
/public/assets
+/dkim
+
# Ignore master key for decrypting credentials and more.
/config/master.key
/config/credentials.yml.enc
diff --git a/Gemfile b/Gemfile
index 628b25e..b05c0df 100644
--- a/Gemfile
+++ b/Gemfile
@@ -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"
diff --git a/Gemfile.lock b/Gemfile.lock
index afcb1ee..e807050 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -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)
diff --git a/app/assets/fonts/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2 b/app/assets/fonts/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2
new file mode 100644
index 0000000..e92882f
Binary files /dev/null and b/app/assets/fonts/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2 differ
diff --git a/app/assets/images/ico-h1.svg b/app/assets/images/ico-h1.svg
new file mode 100644
index 0000000..923f83b
--- /dev/null
+++ b/app/assets/images/ico-h1.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/ico-h2.svg b/app/assets/images/ico-h2.svg
new file mode 100644
index 0000000..ae5e530
--- /dev/null
+++ b/app/assets/images/ico-h2.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/ico-h3.svg b/app/assets/images/ico-h3.svg
new file mode 100644
index 0000000..4f46f42
--- /dev/null
+++ b/app/assets/images/ico-h3.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/ikea-foundation-white.svg b/app/assets/images/ikea-foundation-white.svg
new file mode 100644
index 0000000..d0d99a0
--- /dev/null
+++ b/app/assets/images/ikea-foundation-white.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/ikea-foundation.svg b/app/assets/images/ikea-foundation.svg
new file mode 100644
index 0000000..e28bb85
--- /dev/null
+++ b/app/assets/images/ikea-foundation.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/stylesheets/admin.css b/app/assets/stylesheets/admin.css
new file mode 100644
index 0000000..f2d4d04
--- /dev/null
+++ b/app/assets/stylesheets/admin.css
@@ -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;
+ }
+
+}
+
diff --git a/app/assets/stylesheets/assets.css b/app/assets/stylesheets/assets.css
new file mode 100644
index 0000000..9ce584d
--- /dev/null
+++ b/app/assets/stylesheets/assets.css
@@ -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;
+ }
+
+}
diff --git a/app/assets/stylesheets/attachments.css b/app/assets/stylesheets/attachments.css
new file mode 100644
index 0000000..cb9f423
--- /dev/null
+++ b/app/assets/stylesheets/attachments.css
@@ -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%;
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/form.css b/app/assets/stylesheets/form.css
new file mode 100644
index 0000000..3396e9a
--- /dev/null
+++ b/app/assets/stylesheets/form.css
@@ -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;
+}
diff --git a/app/assets/stylesheets/forms.css b/app/assets/stylesheets/forms.css
new file mode 100644
index 0000000..744c184
--- /dev/null
+++ b/app/assets/stylesheets/forms.css
@@ -0,0 +1,1061 @@
+#main:has(.attachment-container) {
+ padding: 0 0;
+
+}
+
+
+.form-section,
+.form-ctrls {
+ border-top: 1px solid var(--border);
+ margin: 1em 0;
+ padding: 3em 0 1em 0;
+}
+
+.form-ctrls-overlay {
+ margin: 0;
+ padding: 1em 0;
+}
+
+.form-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+
+ & h1 {
+ margin-top: 0.2em;
+ }
+}
+
+.form-header__buttons {
+ display: flex;
+ gap: 8px;
+
+}
+
+.form-header__titled {
+ display: flex;
+ align-items: center;
+ gap: 30px;
+
+ & .title-box {
+ flex-grow: 1;
+ }
+ & [data-icon] {
+ display: flex;
+ gap: 6px;
+ align-items: flex-start;
+
+ &::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;
+ margin-top: 6px;
+ content: attr(data-icon);
+ }
+ }
+}
+
+.form-header__attachment {
+
+ & .title-box {
+ display: block;
+ margin: 1em 4px 1em -8px;
+ }
+
+}
+
+
+
+
+.field {
+ max-width: 990px;
+ display: flex;
+ align-items: start;
+ padding-bottom: 2.4rem;
+ &>div:nth-child(1) {
+ padding-top: 12px;
+ flex-basis: 33%;
+ & label {
+ font-size: 1.4rem;
+ line-height: 1.2;
+ color: #555;
+ }
+ }
+
+ & .button-container {
+ margin-top: 8px;
+ margin-left: -8px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ }
+
+ &>div:nth-child(2) {
+ flex-grow: 0;
+ flex-basis: 66%;
+ }
+
+
+
+ & .field_with_errors {
+ --border: var(--error);
+ }
+
+
+}
+
+.i18n__input label,
+.i18n__label {
+ &::after {
+ content: 'flag';
+ font-family: var(--font-icons);
+ color: #EC5F59;
+ margin-left: 0.2em;
+ font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
+ }
+}
+
+.attachment label {
+ font-size: 1.4rem;
+ line-height: 1.2;
+ color: #555;
+}
+
+.material__input {
+ display: flex;
+ font-family: var(--font-base);
+ border: 1px solid var(--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;
+ }
+
+.material__input-select {
+ appearance: none;
+ position: relative;
+ padding-right: 24px;
+ padding-left: 4px;
+ background-image: url(data:image/svg+xml,%3Csvg%20width%3D%2210px%22%20height%3D%225px%22%20viewBox%3D%227%2010%2010%205%22%20version%3D%221.1%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%3E%0A%20%20%20%20%3Cpolygon%20id%3D%22Shape%22%20stroke%3D%22none%22%20fill%3D%22%230%22%20fill-rule%3D%22evenodd%22%20opacity%3D%220.54%22%20points%3D%227%2010%2012%2015%2017%2010%22%3E%3C%2Fpolygon%3E%0A%3C%2Fsvg%3E);
+ background-repeat: no-repeat;
+ background-position: 100% 50%;
+
+}
+
+.date-fields {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr 2fr;
+ gap: 20px;
+
+
+ & .field {
+ display: block;
+ padding: 0;
+
+ &>div:nth-child(1), &>div:nth-child(2) {
+ padding-top: 0;
+ flex-basis: 1;
+ flex-grow: 1;
+ width: 100%;
+ }
+
+ &>div:nth-child(1) {
+ margin-bottom: 2px;
+ }
+ }
+
+ &[data-n='0'] {
+ &>div:nth-child(2), &>div:nth-child(3) {
+ & label {
+ color: var(--clr-grey-200);
+ }
+ }
+ }
+ &[data-n='1'] {
+ &>div:nth-child(3) {
+ & label {
+ color: var(--clr-grey-200);
+ }
+ }
+ }
+}
+
+
+.title-box {
+
+ & div {
+ display: flex;
+ width: 100%;
+ flex-direction: column;
+ }
+
+
+ & input[type="text"] {
+ display: flex;
+ flex: 4;
+
+ font-family: var(--font-base);
+ border: 1px solid var(--border);
+ font-size: 2.4rem;
+ line-height: 2;
+ font-weight: 400;
+ letter-spacing: 0.00625em;
+ border: none !important;
+ background-color: transparent;
+
+ height: 100%;
+ appearance: none;
+ padding: 8px;
+ border-radius: 4px;
+ box-shadow: none;
+
+ &:focus {
+ outline: 2px solid var(--action);
+ outline-offset: -2px;
+ }
+ }
+}
+
+.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(--action);
+ border-radius: 4px;
+ border: 1px solid var(--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(--inactive);
+ }
+
+ &:focus-within {
+ outline: 2px solid var(--outline);
+ outline-offset: -2px;
+ }
+
+ &:has(.field_with_errors) {
+ --outline: var(--error);
+
+ &:hover {
+ border-color: var(--error);
+ }
+ }
+}
+
+.input-box-url {
+
+ & .base__url {
+ flex-shrink: 0;
+ }
+
+ & span {
+ font-size: 1.6rem;
+ letter-spacing: 0.00625em;
+ color: var(--clr-grey-400);
+
+ }
+}
+
+.i18n__input,
+.title-box .i18n__input {
+ display: none;
+}
+
+form[data-locale='da'] .i18n__input-da,
+form[data-locale='en'] .i18n__input-en,
+form[data-locale='de'] .i18n__input-de {
+ display: flex;
+}
+
+
+.icon-cb,
+.icon-rb {
+ display: block;
+ position: relative;
+ cursor: pointer;
+ user-select: none;
+
+ & svg rect {
+ fill: #ccc !important;
+ }
+
+ & input {
+ position: absolute;
+ opacity: 0;
+ cursor: pointer;
+ height: 0;
+ width: 0;
+
+ &:checked~.cm {
+ color: #ccc;
+ }
+
+ &:checked~svg rect {
+ fill: #585857 !important;
+ }
+ }
+
+ &:focus-within {
+ .cm {
+ outline: 2px solid var(--action);
+ outline-offset: -4px;
+ }
+ }
+
+ & .cm {
+ color: #ccc;
+ display: block;
+ height: 28px;
+ width: 28px;
+ font: 28px/1 var(--font-icons);
+ font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
+ }
+
+ &.toogle {
+ & .cm>*:nth-child(2) {
+ display: none;
+ }
+
+ & .cm {
+ color: var(--border);
+ }
+
+ & input {
+ &:checked~.cm {
+ color: var(--action);
+
+ &>*:nth-child(1) {
+ display: none;
+ }
+
+ &>*:nth-child(2) {
+ display: inline;
+ }
+ }
+ &:disabled~.cm {
+ color: var(--clr-grey-500);
+ }
+ }
+ }
+}
+
+p[role="alert"] {
+ margin: 4px 0 0 0;
+ color: var(--error);
+ font-size: 1.2rem;
+}
+
+.icon-rb {
+ & input {
+ &:checked~.cm {
+ color: #000;
+ background: #ccc;
+ border-radius: 50%;
+ }
+ }
+
+ & .cm {
+ color: #ccc;
+ display: block;
+ text-align: center;
+ text-transform: uppercase;
+ height: 24px;
+ width: 24px;
+ font: 400 1rem/24px "Roboto Mono", monospace;
+ }
+}
+
+.form-ctrls input[type="submit"],
+.form-ctrls button {
+ font-size: 1.5rem;
+ letter-spacing: 0.00625em;
+ padding: 0.6em 1.4em;
+ background: var(--action);
+ border-radius: 4px;
+ color: var(--icon-active);
+ text-decoration: none;
+ font-family: var(--base);
+ border: none;
+ cursor: pointer;
+ box-sizing: border-box;
+}
+
+.btn {
+ display: inline-flex;
+ font-size: 1.4rem;
+ letter-spacing: 0.00625em;
+ padding: 0 8px 0 8px;
+ border-radius: 8px;
+ text-decoration: none;
+ color: var(--action);
+ position: relative;
+ align-items: center;
+ line-height: 32px;
+
+ &:hover {
+ background-color: var(--clr-grey-100);
+ }
+}
+
+
+
+.add-btn, .icon-only-btn, .action-btn {
+ font-size: 1.6rem;
+ letter-spacing: 0.00625em;
+ padding: 0 14px 0 32px;
+ border-radius: 12px;
+ text-decoration: none;
+ color: var(--black);
+ position: relative;
+ align-items: center;
+ line-height: 48px;
+ box-shadow: 0px 0px 1.5px rgba(0, 0, 0, 0.105), 0px 1px 3px rgba(0, 0, 0, 0.21);
+ background-color: transparent;
+ border: none;
+ &:hover {
+ background-color: var(--clr-grey-200);
+ }
+}
+
+.icon-btn {
+ display: inline-flex;
+ font-size: 1.4rem;
+ letter-spacing: 0.00625em;
+ padding: 0 8px 0 24px;
+ color: var(--black);
+ position: relative;
+ align-items: center;
+ line-height: 32px;
+ background-color: transparent;
+ border: none;
+ & span::before {
+ position: absolute;
+ content: attr(data-icon);
+ left: 2px;
+ font-family: var(--font-icons);
+ font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
+ font-size: 2.0rem;
+ line-height: 32px;
+ }
+}
+
+.icon-only-btn {
+ display: inline-flex;
+ font-size: 1.4rem;
+ letter-spacing: 0.00625em;
+ padding: 0 8px;
+ color: var(--black);
+ position: relative;
+ align-items: center;
+ font-family: var(--font-icons);
+ font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
+ font-size: 3.2rem;
+ line-height: 32px;
+
+ &:disabled {
+ color: var(--clr-grey-400);
+ cursor: not-allowed;
+ &:hover {
+ background-color: transparent;
+ }
+ }
+}
+
+.add-btn {
+ & span::before {
+ position: absolute;
+ content: 'add';
+ left: 2px;
+ font-family: var(--font-icons);
+ font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
+ font-size: 3.2rem;
+ line-height: 48px;
+ }
+}
+
+.action-btn {
+ & span::before {
+ position: absolute;
+ content: attr(data-icon);
+ left: 2px;
+ font-family: var(--font-icons);
+ font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
+ font-size: 2.8rem;
+ line-height: 48px;
+ }
+}
+
+.form-ctrls {
+ display: flex;
+ justify-content: end;
+ align-items: center;
+ gap: 20px;
+}
+
+.form-nav-links {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 1rem;
+
+
+}
+
+.i18n__from-ctrls {
+ display: flex;
+ gap: 4px;
+ & label {
+ font-size: 1.5rem;
+ cursor: pointer;
+ text-transform: uppercase;
+ letter-spacing: 0.00625em;
+ }
+ & span {
+ border-radius: 50%;
+ display: inline-block;
+ width: 32px;
+ height: 32px;
+ line-height: 32px;
+ text-align: center;
+ user-select: none;
+ }
+ & input {
+ position: absolute;
+ opacity: 0;
+ cursor: pointer;
+ height: 0;
+
+ &:checked + span {
+ background-color: var(--clr-grey-600);
+ color: var(--white);
+ }
+ }
+}
+
+.form-ctrls a,
+.delete-link {
+ color: var(--secondary);
+ text-decoration: none;
+ font-size: 1.5rem;
+ letter-spacing: 0.00625em;
+ appearance: none;
+ background: none;
+ border: none;
+}
+
+.dropzone {
+ width: 80px;
+ height: 80px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-top: 1em;
+ box-sizing: border-box;
+ cursor: pointer;
+
+ border: 2px dotted var(--clr-grey-200);
+ border-radius: 8px;
+
+ & input[type=file] {
+ display: none;
+ }
+
+ &:hover {
+ box-shadow: 0px 0px 1.5px rgba(0, 0, 0, 0.105), 0px 1px 3px rgba(0, 0, 0, 0.21);
+ border-color: var(--clr-grey-200);
+ background-color: var(--clr-grey-200);
+
+ & span {
+ color: var(--clr-black);
+ }
+ }
+
+
+ & span {
+ color: var(--clr-grey-500);
+ font-family: var(--font-icons);
+ font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
+
+ font-size: 4.2rem;
+ }
+}
+
+.dropzone--small {
+ margin-top: 0;
+ width: 48px;
+ height: 48px;
+
+ & span {
+ font-size: 3.2rem;
+ }
+}
+
+.drag-over {
+ border-color: var(--clr-grey-400);
+ border-style: solid;
+ background-color: var(--clr-grey-200);
+
+ & span {
+ color: var(--clr-black);
+ }
+}
+
+#attachments {
+ gap: 0;
+}
+
+
+
+.file-details {
+ text-align: center;
+ font: 1.2rem var(--font-mono);
+
+}
+
+.file-name {
+ hyphens: auto;
+ word-break: break-word;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ margin-bottom: 2px;
+}
+
+.file-data {
+ color: var(--action);
+}
+
+.file-data-large {
+ font: 1.4rem var(--font-mono);
+}
+
+.asset-ctrls {
+ display: none;
+ gap: 4px;
+ justify-content: center;
+ font: 2.4rem var(--font-icons);
+ font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
+
+ & > div {
+ display: flex;
+ gap: 4px;
+ }
+
+ & a, & .handle {
+ display: inline-flex;
+ width: 28px;
+ height: 28px;
+ justify-content: center;
+ align-items: center;
+ border-radius: 50%;
+ text-decoration: none;
+ color: var(--clr-grey-400);
+
+ &:hover {
+ background-color: var(--clr-grey-200);
+ color: var(--clr-grey-600);
+ }
+ }
+}
+
+
+.asset-thumbnail {
+ position: relative;
+ width: 100%;
+
+ & img {
+ position: absolute;
+ 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;
+ }
+
+ &::after {
+ content: "";
+ display: block;
+ padding-bottom: 100%;
+ }
+}
+
+.image-container {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+#node-tags {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 20px;
+
+ font-size: 1.6rem;
+
+ & label {
+ font-size: 1.4rem;
+ color: #555;
+ }
+
+}
+
+#overlay {
+ position: fixed;
+ inset: 0 0 0 0;
+ background-color: rgba(32,33,36,0.6);
+ z-index: 1100;
+
+}
+
+
+.inner-window-container {
+ position: fixed;
+ inset: 50px 0 0 0;
+ overflow: hidden;
+ background-color: var(--bg);
+ border-radius: 20px 20px 0 0;
+}
+
+#inner-window {
+ position: absolute;
+ inset: 60px 40px 0 40px;
+ display: flex;
+ flex-direction: column;
+
+ & .list-container {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ }
+
+ & .list {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ padding: 0;
+
+ }
+
+ & .assets__container {
+ flex: 1 1 0;
+ overflow-y: scroll;
+ }
+}
+
+
+.close-overlay {
+ position: absolute;
+ top: 20px;
+ right: 20px;
+ font: 3.2rem/36px var(--font-icons);
+ font-variation-settings: 'opsz' 24, 'wght' 300, 'FILL' 1, 'GRAD' 0;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ color: var(--clr-black);
+ border: none;
+ background-color: transparent;
+
+ &:hover {
+ color: var(--black);
+ background-color: var(--clr-grey-200);
+ }
+}
+
+.tag-context {
+ & .tag-container {
+ margin-top: 2px;
+ }
+}
+
+.tag-container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.tag {
+ font: 1.4rem/1.4 var(--font-mono);
+ background: var(--clr-grey-300);
+ padding: 2px 6px 3px 6px;
+ border-radius: 4px;
+ text-decoration: none;
+ color: var(--black);
+}
+
+.parent__tag {
+ background: var(--clr-grey-500);
+ color: var(--clr-grey-100);
+}
+
+.trix-button-group--file-tools {
+ display: none !important;
+}
+
+trix-toolbar, trix-editor {
+ font-size: 1.6rem;
+}
+
+trix-editor {
+
+ & h1 {
+ font-weight: 600;
+ font-size: 2.4rem;
+ line-height: 1.25;
+ margin: 0 0 0.4em 0;
+ }
+
+ & h2 {
+ font-weight: 600;
+ font-size: 1.8rem;
+ line-height: 1.25;
+ margin: 0 0 0.4em 0;
+ }
+
+ & ul, ol {
+ display: block;
+ margin-block-start: 1em;
+ margin-block-end: 1em;
+ padding-inline-start: 1.2em;
+ }
+ & ul {
+ list-style-type: disc;
+ }
+ & ol {
+ list-style-type: decimal;
+ }
+
+ & a {
+ color: blue;
+ }
+
+ & pre {
+ background-color: #eee;
+ padding: 0.5em;
+ }
+
+ & blockquote {
+ margin: 1em 0;
+ padding: 0.5em;
+ background-color: #eee;
+ &::before {
+ content: '"';
+ color: #999;
+ }
+ &::after {
+ content: '"';
+ color: #999;
+ }
+ }
+
+
+}
+
+.trix-button--icon-heading-1::before {
+ background-image: url("ico-h1.svg") !important;
+}
+
+.trix-button--icon-heading-2::before {
+ background-image: url("ico-h2.svg");
+}
+
+.trix-button--icon-heading-3::before {
+ background-image: url("ico-h3.svg");
+}
+
+
+.attachment-container {
+ padding-left: calc((100vw - 80px) / 2);
+ margin: 30px 40px;
+}
+
+.image-viewer {
+ position: fixed;
+ left: 80px;
+ top: 0;
+ bottom: 0;
+ width: calc((100vw - 80px) / 2);
+ border-right: 1px solid var(--border);
+}
+
+
+
+
+#scan-result {
+ font-size: 1.6rem;
+ border-radius: 4px;
+
+ &:has(div) {
+ background-color: var(--clr-grey-100);
+ margin-top: 1em;
+ padding: 1em;
+ }
+
+
+ & > div {
+ tab-size: 4;
+ text-align: left;
+ white-space: pre;
+ width: auto;
+ word-break: normal;
+ word-spacing: 0px;
+ }
+
+
+}
+
+
+.date-formatted {
+ font-size: 1.6rem;
+ margin-top: 12px;
+}
+
+
+
+ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+
+}
+
+.node__status {
+ font-size: 1.6rem;
+ letter-spacing: 0.00625em;
+ line-height: 1.2;
+
+ & li+li {
+ margin-top: 0.4em;
+ }
+
+ li {
+ display: flex;
+ gap: 0.4em;
+ }
+}
+
+.datetime__select {
+ display: flex;
+ justify-content: flex-start;
+ align-items: center;
+ gap: 0.6em;
+ font-size: 1.6rem;
+ letter-spacing: 0.00625em;
+
+ select {
+ height: 3.2rem;
+ &:focus {
+ &:focus {
+ outline: 2px solid var(--action);
+ }
+
+ }
+ }
+
+ & button {
+ 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.8rem;
+ line-height: 1.142857142857143;
+ appearance: none;
+ background: none;
+ border: none;
+ padding: 0;
+ }
+
+ & select {
+ width: auto;
+ padding-right: 20px;
+ }
+
+ &.with-toggles {
+ & button:nth-of-type(1) {
+ display: none;
+ }
+ & button:nth-of-type(2) {
+ display: inline;
+ }
+
+ &:has(select:disabled) {
+
+ & span, select {
+ display: none;
+ }
+
+ & button:nth-of-type(1) {
+ display: inline;
+ }
+ & button:nth-of-type(2) {
+ display: none;
+ }
+ }
+
+ }
+}
+
+.ts-wrapper {
+ flex-grow: 1;
+ align-self: center;
+}
+
+.ts-control {
+ border: none;
+ padding: 8px 0;
+}
+
+.ts-dropdown, .ts-control, .ts-control input {
+ font-size: 1.6rem;
+}
+
+.ts-dropdown .active {
+ background-color: var(--clr-grey-300);
+ color: var(--black);
+}
+
+.ts-wrapper.multi .ts-control > div {
+ background-color: var(--clr-grey-300);
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/lists.css b/app/assets/stylesheets/lists.css
new file mode 100644
index 0000000..ca3efb4
--- /dev/null
+++ b/app/assets/stylesheets/lists.css
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/nodes.css b/app/assets/stylesheets/nodes.css
new file mode 100644
index 0000000..194af39
--- /dev/null
+++ b/app/assets/stylesheets/nodes.css
@@ -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;
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/popup-menu.css b/app/assets/stylesheets/popup-menu.css
new file mode 100644
index 0000000..f9b3b0b
--- /dev/null
+++ b/app/assets/stylesheets/popup-menu.css
@@ -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%;
+ }
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/sessions.css b/app/assets/stylesheets/sessions.css
new file mode 100644
index 0000000..e308ad9
--- /dev/null
+++ b/app/assets/stylesheets/sessions.css
@@ -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;
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/tom-select.css b/app/assets/stylesheets/tom-select.css
new file mode 100644
index 0000000..5634e64
--- /dev/null
+++ b/app/assets/stylesheets/tom-select.css
@@ -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 */
\ No newline at end of file
diff --git a/app/assets/stylesheets/trix.css b/app/assets/stylesheets/trix.css
new file mode 100644
index 0000000..a7447a8
--- /dev/null
+++ b/app/assets/stylesheets/trix.css
@@ -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%; }
diff --git a/app/controllers/admin/admin_controller.rb b/app/controllers/admin/admin_controller.rb
new file mode 100644
index 0000000..19e9dc0
--- /dev/null
+++ b/app/controllers/admin/admin_controller.rb
@@ -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
diff --git a/app/controllers/admin/assets_controller.rb b/app/controllers/admin/assets_controller.rb
new file mode 100644
index 0000000..5ad60c3
--- /dev/null
+++ b/app/controllers/admin/assets_controller.rb
@@ -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
diff --git a/app/controllers/admin/attachments_controller.rb b/app/controllers/admin/attachments_controller.rb
new file mode 100644
index 0000000..8d868cb
--- /dev/null
+++ b/app/controllers/admin/attachments_controller.rb
@@ -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
diff --git a/app/controllers/admin/nodes_controller.rb b/app/controllers/admin/nodes_controller.rb
new file mode 100644
index 0000000..c688608
--- /dev/null
+++ b/app/controllers/admin/nodes_controller.rb
@@ -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
diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb
new file mode 100644
index 0000000..ab8c1d1
--- /dev/null
+++ b/app/controllers/admin/sessions_controller.rb
@@ -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
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
new file mode 100644
index 0000000..c41ec28
--- /dev/null
+++ b/app/controllers/admin/users_controller.rb
@@ -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
diff --git a/app/helpers/admin/admin_helper.rb b/app/helpers/admin/admin_helper.rb
new file mode 100644
index 0000000..c6dc30e
--- /dev/null
+++ b/app/helpers/admin/admin_helper.rb
@@ -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
diff --git a/app/helpers/admin/assets_helper.rb b/app/helpers/admin/assets_helper.rb
new file mode 100644
index 0000000..bb24b59
--- /dev/null
+++ b/app/helpers/admin/assets_helper.rb
@@ -0,0 +1,2 @@
+module Admin::AssetsHelper
+end
diff --git a/app/helpers/admin/attachments_helper.rb b/app/helpers/admin/attachments_helper.rb
new file mode 100644
index 0000000..e272eec
--- /dev/null
+++ b/app/helpers/admin/attachments_helper.rb
@@ -0,0 +1,2 @@
+module Admin::AttachmentsHelper
+end
diff --git a/app/helpers/admin/nodes_helper.rb b/app/helpers/admin/nodes_helper.rb
new file mode 100644
index 0000000..74043f9
--- /dev/null
+++ b/app/helpers/admin/nodes_helper.rb
@@ -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
diff --git a/app/helpers/admin/users_helper.rb b/app/helpers/admin/users_helper.rb
new file mode 100644
index 0000000..5995c2a
--- /dev/null
+++ b/app/helpers/admin/users_helper.rb
@@ -0,0 +1,2 @@
+module Admin::UsersHelper
+end
diff --git a/app/javascript/admin.js b/app/javascript/admin.js
new file mode 100644
index 0000000..18a2582
--- /dev/null
+++ b/app/javascript/admin.js
@@ -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 = 'Heading ';
+
+ 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?://|/).+"
+})
+
diff --git a/app/javascript/controllers/assets_controller.js b/app/javascript/controllers/assets_controller.js
new file mode 100644
index 0000000..1390627
--- /dev/null
+++ b/app/javascript/controllers/assets_controller.js
@@ -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
+ }
+
+
+}
diff --git a/app/javascript/controllers/fields_controller.js b/app/javascript/controllers/fields_controller.js
new file mode 100644
index 0000000..8f30374
--- /dev/null
+++ b/app/javascript/controllers/fields_controller.js
@@ -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';
+ }
+
+}
diff --git a/app/javascript/controllers/hello_controller.js b/app/javascript/controllers/hello_controller.js
deleted file mode 100644
index 5975c07..0000000
--- a/app/javascript/controllers/hello_controller.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { Controller } from "@hotwired/stimulus"
-
-export default class extends Controller {
- connect() {
- this.element.textContent = "Hello World!"
- }
-}
diff --git a/app/javascript/controllers/i18n_form_controller.js b/app/javascript/controllers/i18n_form_controller.js
new file mode 100644
index 0000000..dc9f0dc
--- /dev/null
+++ b/app/javascript/controllers/i18n_form_controller.js
@@ -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)
+ }
+
+
+}
+
diff --git a/app/javascript/controllers/load_more_controller.js b/app/javascript/controllers/load_more_controller.js
new file mode 100644
index 0000000..ce875b0
--- /dev/null
+++ b/app/javascript/controllers/load_more_controller.js
@@ -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
+ }
+
+
+}
diff --git a/app/javascript/controllers/navbar_controller.js b/app/javascript/controllers/navbar_controller.js
new file mode 100644
index 0000000..5f5ff85
--- /dev/null
+++ b/app/javascript/controllers/navbar_controller.js
@@ -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')
+ }
+
+}
diff --git a/app/javascript/controllers/nodes_controller.js b/app/javascript/controllers/nodes_controller.js
new file mode 100644
index 0000000..711d11a
--- /dev/null
+++ b/app/javascript/controllers/nodes_controller.js
@@ -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')
+ }
+ }
+
+}
diff --git a/app/javascript/controllers/popup_controller.js b/app/javascript/controllers/popup_controller.js
new file mode 100644
index 0000000..9077c70
--- /dev/null
+++ b/app/javascript/controllers/popup_controller.js
@@ -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)
+ }
+ }
+
+}
+
diff --git a/app/javascript/controllers/select_controller.js b/app/javascript/controllers/select_controller.js
new file mode 100644
index 0000000..ef8b77d
--- /dev/null
+++ b/app/javascript/controllers/select_controller.js
@@ -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();
+ }
+ }
+}
diff --git a/app/javascript/controllers/sort_controller.js b/app/javascript/controllers/sort_controller.js
new file mode 100644
index 0000000..d265b04
--- /dev/null
+++ b/app/javascript/controllers/sort_controller.js
@@ -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
+ })
+ }
+ }
+
+
+}
diff --git a/app/javascript/controllers/upload_controller.js b/app/javascript/controllers/upload_controller.js
new file mode 100644
index 0000000..ec02658
--- /dev/null
+++ b/app/javascript/controllers/upload_controller.js
@@ -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 = '0 '
+ } else if (this.targetValue == 'tree-nodes') {
+ target_div.classList.add('row')
+ target_div.innerHTML = '
0
'
+ }
+ 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
+ }
+}
diff --git a/app/javascript/controllers/utils_controller.js b/app/javascript/controllers/utils_controller.js
new file mode 100644
index 0000000..03da45a
--- /dev/null
+++ b/app/javascript/controllers/utils_controller.js
@@ -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;
+ })
+ }
+
+
+
+}
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
index 3c34c81..a2aed4d 100644
--- a/app/mailers/application_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -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
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
new file mode 100644
index 0000000..961f4ab
--- /dev/null
+++ b/app/mailers/user_mailer.rb
@@ -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
diff --git a/app/models/asset.rb b/app/models/asset.rb
new file mode 100644
index 0000000..8ddae57
--- /dev/null
+++ b/app/models/asset.rb
@@ -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
diff --git a/app/models/attachment.rb b/app/models/attachment.rb
new file mode 100644
index 0000000..ab7e189
--- /dev/null
+++ b/app/models/attachment.rb
@@ -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
diff --git a/app/models/concerns/ancestry_with_sorted_url.rb b/app/models/concerns/ancestry_with_sorted_url.rb
new file mode 100644
index 0000000..6b342f6
--- /dev/null
+++ b/app/models/concerns/ancestry_with_sorted_url.rb
@@ -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
diff --git a/app/models/concerns/has_attachments.rb b/app/models/concerns/has_attachments.rb
new file mode 100644
index 0000000..3d7ebeb
--- /dev/null
+++ b/app/models/concerns/has_attachments.rb
@@ -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
diff --git a/app/models/concerns/has_tags.rb b/app/models/concerns/has_tags.rb
new file mode 100644
index 0000000..6b669c0
--- /dev/null
+++ b/app/models/concerns/has_tags.rb
@@ -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
diff --git a/app/models/current.rb b/app/models/current.rb
new file mode 100644
index 0000000..73a9744
--- /dev/null
+++ b/app/models/current.rb
@@ -0,0 +1,3 @@
+class Current < ActiveSupport::CurrentAttributes
+ attribute :user
+end
diff --git a/app/models/node.rb b/app/models/node.rb
new file mode 100644
index 0000000..0a3cfca
--- /dev/null
+++ b/app/models/node.rb
@@ -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
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 0000000..19c9e2c
--- /dev/null
+++ b/app/models/user.rb
@@ -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
diff --git a/app/models/verification_code.rb b/app/models/verification_code.rb
new file mode 100644
index 0000000..c324755
--- /dev/null
+++ b/app/models/verification_code.rb
@@ -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
diff --git a/app/views/admin/assets/_asset.html.erb b/app/views/admin/assets/_asset.html.erb
new file mode 100644
index 0000000..f0c55f8
--- /dev/null
+++ b/app/views/admin/assets/_asset.html.erb
@@ -0,0 +1,33 @@
+<%= tag.div class: 'asset', id: dom_id(asset) do %>
+
+
+
<%= image_tag rails_storage_proxy_path(asset.file.representation(resize_to_limit: [320,320], format: :jpg)) if asset.file.representable? %>
+
+
+ <%= 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%>
+
+ <%- end -%>
+
+
+
+
+ <%= 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') %>
+
+ <%= link_to 'delete_forever', admin_asset_path(asset), data: { turbo_confirm: t(:'ui.are_you_sure'), turbo_method: :delete }, title: t('ui.delete') %>
+
+
+
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/admin/assets/_form.html.erb b/app/views/admin/assets/_form.html.erb
new file mode 100644
index 0000000..f8afae6
--- /dev/null
+++ b/app/views/admin/assets/_form.html.erb
@@ -0,0 +1,14 @@
+ <%= form_with(model: [ :admin, asset ], class: 'form-plain') do |form| %>
+
+
+
+ <%= render partial: 'material/text_field', locals: { f: form, attr: :title } %>
+
+
+
+
+ <%= link_to t(:'ui.cancel'), url_for(controller: 'assets', action: 'index'), data: {turbo_action: 'advance'} %>
+ <%= form.submit t(:'ui.save') %>
+
+
+ <% end %>
diff --git a/app/views/admin/assets/_list.html.erb b/app/views/admin/assets/_list.html.erb
new file mode 100644
index 0000000..be40fba
--- /dev/null
+++ b/app/views/admin/assets/_list.html.erb
@@ -0,0 +1,51 @@
+
+
<%= link_to yield(:title), params.permit(:sort, :reverse), class: 'list-title-link' %>
+
+
+ <%= 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 %>
+ arrow_upward
+
+ <% end %>
+ <% end %>
+
+
+
+
+
+
+
+ <%= render partial: 'sort' %>
+
+
+ <%= entries_info @assets %>
+
+
+
+
+
+
+
+
+ <%= render @assets %>
+
+
+ <%= 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? %>
+
+
+
\ No newline at end of file
diff --git a/app/views/admin/assets/_sort.html.erb b/app/views/admin/assets/_sort.html.erb
new file mode 100644
index 0000000..bb4331f
--- /dev/null
+++ b/app/views/admin/assets/_sort.html.erb
@@ -0,0 +1,12 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/views/admin/assets/destroy.turbo_stream.erb b/app/views/admin/assets/destroy.turbo_stream.erb
new file mode 100644
index 0000000..a9df7a2
--- /dev/null
+++ b/app/views/admin/assets/destroy.turbo_stream.erb
@@ -0,0 +1,3 @@
+<%= turbo_stream.remove @asset %>
+
+<%= turbo_stream.append 'flash', partial: 'layouts/flash' %>
\ No newline at end of file
diff --git a/app/views/admin/assets/edit.html.erb b/app/views/admin/assets/edit.html.erb
new file mode 100644
index 0000000..32d4bc2
--- /dev/null
+++ b/app/views/admin/assets/edit.html.erb
@@ -0,0 +1,25 @@
+<%= content_for :title, @asset.title %>
+
+<%= turbo_frame_tag 'main' do %>
+
+ <%= turbo_stream.append 'flash', partial: 'layouts/flash' %>
+
+
+ <%= link_to t(:'assets.list'), url_for(controller: 'assets', action: 'index'), data: {turbo_action: 'advance'}, class: 'back-link' %>
+
+
+
+ <%= render "form", asset: @asset %>
+
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/admin/assets/explore.turbo_stream.erb b/app/views/admin/assets/explore.turbo_stream.erb
new file mode 100644
index 0000000..9643dd0
--- /dev/null
+++ b/app/views/admin/assets/explore.turbo_stream.erb
@@ -0,0 +1,57 @@
+<%= content_for :title, t(:'assets.title') %>
+
+<% if request.query_string.blank? %>
+
+
+
+
+
+
+ <%= button_tag 'close', class: 'close-overlay', data: { action: "click->utils#closeOverlay" } %>
+
+
+ <%= render partial: 'list', formats: :html %>
+
+
+ <%= button_tag t(:'ui.append'), data: { action: "click->assets#appendAttachments" } %>
+
+
+
+
+
+
+
+
+<% else %>
+
+
+
+ <%= render partial: 'asset',
+ collection: @assets,
+ formats: :html %>
+
+
+
+
+
+ <%= render partial: 'sort', formats: :html %>
+
+
+
+
+
+ <% if @assets.blank? or @assets.last_page? %>
+
+ <% 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 %>
+
+
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/admin/assets/index.html.erb b/app/views/admin/assets/index.html.erb
new file mode 100644
index 0000000..b77ea89
--- /dev/null
+++ b/app/views/admin/assets/index.html.erb
@@ -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 %>
\ No newline at end of file
diff --git a/app/views/admin/assets/index.turbo_stream.erb b/app/views/admin/assets/index.turbo_stream.erb
new file mode 100644
index 0000000..b012328
--- /dev/null
+++ b/app/views/admin/assets/index.turbo_stream.erb
@@ -0,0 +1,30 @@
+
+
+ <%= render partial: 'asset',
+ collection: @assets,
+ formats: :html %>
+
+
+
+
+
+
+ <%= render partial: 'sort', formats: :html %>
+
+
+
+
+
+ <% if @assets.blank? or @assets.last_page? %>
+
+ <% 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 %>
+
+
\ No newline at end of file
diff --git a/app/views/admin/assets/upload.turbo_stream.erb b/app/views/admin/assets/upload.turbo_stream.erb
new file mode 100644
index 0000000..58b4632
--- /dev/null
+++ b/app/views/admin/assets/upload.turbo_stream.erb
@@ -0,0 +1,7 @@
+
+
+ <%= render partial: 'asset', object: @asset, formats: :html %>
+
+
+
+
diff --git a/app/views/admin/attachments/_asset.html.erb b/app/views/admin/attachments/_asset.html.erb
new file mode 100644
index 0000000..fca5b47
--- /dev/null
+++ b/app/views/admin/attachments/_asset.html.erb
@@ -0,0 +1,14 @@
+
+
+
<%= image_tag rails_storage_proxy_path(asset.file.representation(resize_to_limit: [320,320], format: :jpg)) if asset.file.representable? %>
+
+
+ <%= 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' %>
+
\ No newline at end of file
diff --git a/app/views/admin/attachments/_attachment.html.erb b/app/views/admin/attachments/_attachment.html.erb
new file mode 100644
index 0000000..d21c3ff
--- /dev/null
+++ b/app/views/admin/attachments/_attachment.html.erb
@@ -0,0 +1,43 @@
+
+
+
+ <%= render partial: 'admin/attachments/asset', collection: Array(f.object.asset) %>
+ <%= render partial: 'material/trix_field_i18n', locals: { f: f, attr: :body } %>
+
+
+
+ <%= 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
+ } %>
+
+
+
+
+
+ <%= 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 %>
+
+
more_vert
+
diff --git a/app/views/admin/attachments/new.turbo_stream.erb b/app/views/admin/attachments/new.turbo_stream.erb
new file mode 100644
index 0000000..0630106
--- /dev/null
+++ b/app/views/admin/attachments/new.turbo_stream.erb
@@ -0,0 +1,14 @@
+
+
+ <% @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 %>
+
+
diff --git a/app/views/admin/nodes/_drawer.html.erb b/app/views/admin/nodes/_drawer.html.erb
new file mode 100644
index 0000000..efa7881
--- /dev/null
+++ b/app/views/admin/nodes/_drawer.html.erb
@@ -0,0 +1,5 @@
+
+
+ <%= render Node.roots.ordered %>
+
+
\ No newline at end of file
diff --git a/app/views/admin/nodes/_edit.html.erb b/app/views/admin/nodes/_edit.html.erb
new file mode 100644
index 0000000..1903f33
--- /dev/null
+++ b/app/views/admin/nodes/_edit.html.erb
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/app/views/admin/nodes/_form.html.erb b/app/views/admin/nodes/_form.html.erb
new file mode 100644
index 0000000..2b4bb76
--- /dev/null
+++ b/app/views/admin/nodes/_form.html.erb
@@ -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| %>
+
+
+
+
+
+
+
+
+
+ <%= 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? %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <%= 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') %>
+
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/admin/nodes/_node.html.erb b/app/views/admin/nodes/_node.html.erb
new file mode 100644
index 0000000..13b2cd4
--- /dev/null
+++ b/app/views/admin/nodes/_node.html.erb
@@ -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 %>
+
diff --git a/app/views/admin/nodes/_tree.html.erb b/app/views/admin/nodes/_tree.html.erb
new file mode 100644
index 0000000..d559c94
--- /dev/null
+++ b/app/views/admin/nodes/_tree.html.erb
@@ -0,0 +1,24 @@
+
+
+
+
+ <%= tree_title(@node) %>
+
+
+
+
+
+
+
+
+ <%= render partial: 'tree_node', collection: @node ? @node.children.ordered : Node.roots.ordered, as: 'node' %>
+
+
+
+
\ No newline at end of file
diff --git a/app/views/admin/nodes/_tree_node.html.erb b/app/views/admin/nodes/_tree_node.html.erb
new file mode 100644
index 0000000..feb038b
--- /dev/null
+++ b/app/views/admin/nodes/_tree_node.html.erb
@@ -0,0 +1,38 @@
+
+
+
+
+ <%= 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'
+ } %>
+
+
+
+
+
+
+
+ <%# node_flags(node) %>
+
+ <% node.occasions.each do |occasion| %>
+ <%= tag.li occasion_date(occasion) %>
+ <% end %>
+
+
+
+
+ <%= audit_info node %>
+
+
+
+ <%= link_to 'edit', edit_admin_node_path(node), class:"icon size--medium round", data: {turbo_action: 'advance'} %>
+ <%= tag.div 'more_vert', class: 'handle' %>
+
+
+
\ No newline at end of file
diff --git a/app/views/admin/nodes/children.turbo_stream.erb b/app/views/admin/nodes/children.turbo_stream.erb
new file mode 100644
index 0000000..6ce0c00
--- /dev/null
+++ b/app/views/admin/nodes/children.turbo_stream.erb
@@ -0,0 +1,8 @@
+<%= turbo_stream.append @node do %>
+
+ <% @node.children.ordered.each do |node| %>
+ <%= render node, closed: true %>
+ <% end %>
+
+<% end %>
+
diff --git a/app/views/admin/nodes/create.turbo_stream.erb b/app/views/admin/nodes/create.turbo_stream.erb
new file mode 100644
index 0000000..8f9537c
--- /dev/null
+++ b/app/views/admin/nodes/create.turbo_stream.erb
@@ -0,0 +1,27 @@
+<%= turbo_stream.append "tree-nodes" do %>
+ <%= render partial: 'tree_node', object: @node, as: 'node' %>
+<% end %>
+
+
+<% if @node.parent %>
+
+
+
+ <%= render partial: 'node', object: @node.parent %>
+
+
+
+
+
+ <%= tree_node_title_link @node.parent %>
+
+
+<% else %>
+
+
+
+ <%= render partial: 'node', object: @node %>
+
+
+
+<% end %>
diff --git a/app/views/admin/nodes/destroy.turbo_stream.erb b/app/views/admin/nodes/destroy.turbo_stream.erb
new file mode 100644
index 0000000..d635f5e
--- /dev/null
+++ b/app/views/admin/nodes/destroy.turbo_stream.erb
@@ -0,0 +1,16 @@
+<%= turbo_stream.remove @destroyed_node %>
+
+<% if @node %>
+
+
+ <%= render partial: 'node', object: @node %>
+
+
+<% end %>
+
+
+<%= turbo_stream.update "tree" do %>
+ <%= render partial: "tree" %>
+<% end %>
+
+<%= turbo_stream.append 'flash', partial: 'layouts/flash' %>
\ No newline at end of file
diff --git a/app/views/admin/nodes/edit.html.erb b/app/views/admin/nodes/edit.html.erb
new file mode 100644
index 0000000..02a4a52
--- /dev/null
+++ b/app/views/admin/nodes/edit.html.erb
@@ -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 %>
\ No newline at end of file
diff --git a/app/views/admin/nodes/edit.turbo_stream.erb b/app/views/admin/nodes/edit.turbo_stream.erb
new file mode 100644
index 0000000..fea29dc
--- /dev/null
+++ b/app/views/admin/nodes/edit.turbo_stream.erb
@@ -0,0 +1,5 @@
+<%= turbo_stream.update "tree" do %>
+ <%= render partial: "edit" %>
+<% end %>
+
+
diff --git a/app/views/admin/nodes/index.html.erb b/app/views/admin/nodes/index.html.erb
new file mode 100644
index 0000000..4dbd86c
--- /dev/null
+++ b/app/views/admin/nodes/index.html.erb
@@ -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 %>
+
+
\ No newline at end of file
diff --git a/app/views/admin/nodes/sort.turbo_stream.erb b/app/views/admin/nodes/sort.turbo_stream.erb
new file mode 100644
index 0000000..99b6c18
--- /dev/null
+++ b/app/views/admin/nodes/sort.turbo_stream.erb
@@ -0,0 +1,15 @@
+<% if @node.root? %>
+
+
+ <%= render partial: 'node', collection: Node.roots.ordered, formats: :html %>
+
+
+<% end %>
+
+<% if @node.parent %>
+
+
+ <%= render partial: 'node', object: @node.parent, formats: :html %>
+
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/admin/nodes/tree.turbo_stream.erb b/app/views/admin/nodes/tree.turbo_stream.erb
new file mode 100644
index 0000000..76ef6c7
--- /dev/null
+++ b/app/views/admin/nodes/tree.turbo_stream.erb
@@ -0,0 +1,3 @@
+<%= turbo_stream.update 'tree' do %>
+ <%= render partial: 'tree' %>
+<% end %>
diff --git a/app/views/admin/nodes/update.turbo_stream.erb b/app/views/admin/nodes/update.turbo_stream.erb
new file mode 100644
index 0000000..051eda4
--- /dev/null
+++ b/app/views/admin/nodes/update.turbo_stream.erb
@@ -0,0 +1,10 @@
+<%= turbo_stream.replace "node_form" do %>
+ <%= render "form", node: @node %>
+<% end %>
+
+
+ <%= drawer_node_link @node %>
+
+
+
+<%= turbo_stream.append 'flash', partial: 'layouts/flash' %>
\ No newline at end of file
diff --git a/app/views/admin/sessions/new.html.erb b/app/views/admin/sessions/new.html.erb
new file mode 100644
index 0000000..0df9da6
--- /dev/null
+++ b/app/views/admin/sessions/new.html.erb
@@ -0,0 +1,29 @@
+<%= link_to(svg('ikea-foundation'), root_path) %>
+
+<%= form_tag url_for(action: 'index', locale: nil) do %>
+
+
+ <%= label_tag :email, t('sessions.email') %>
+ <%= label_tag :email, class: "input-box" do %>
+ <%= text_field_tag :email, params[:email] %>
+ <% end %>
+
+
+
+ <%= 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 -%>
+
+
+ <%= button_tag t(:'sessions.login') %>
+
+
+<% end %>
diff --git a/app/views/admin/sessions/verification.html.erb b/app/views/admin/sessions/verification.html.erb
new file mode 100644
index 0000000..5574ba2
--- /dev/null
+++ b/app/views/admin/sessions/verification.html.erb
@@ -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 %>
+
+
+ <%= button_tag t(:'sessions.verify_email') %>
+
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb
new file mode 100644
index 0000000..9731d58
--- /dev/null
+++ b/app/views/admin/users/_form.html.erb
@@ -0,0 +1,27 @@
+
+ <%= form_with(model: [ :admin, user ], class: 'form-plain has--key-ctrls' ) do |form| %>
+
+
+ <%= 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 } %>
+
+
+
+ <%= 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 } %>
+
+
+
+ <%= render partial: 'material/password_field', locals: { f: form, attr: :password } %>
+ <%= render partial: 'material/password_field', locals: { f: form, attr: :password_confirmation } %>
+
+
+
+ <%= link_to t(:'ui.cancel'), url_for(controller: 'users', action: 'index'), data: {turbo_action: 'advance'} %>
+ <%= form.submit t(:'ui.save') %>
+
+
+ <% end %>
diff --git a/app/views/admin/users/_user.html.erb b/app/views/admin/users/_user.html.erb
new file mode 100644
index 0000000..7bed2b2
--- /dev/null
+++ b/app/views/admin/users/_user.html.erb
@@ -0,0 +1,29 @@
+
+
+
+ <%= user.name %>
+ <%= t user.role, scope: 'users.roles' %>
+
+
+
+ <%= user.email %>
+
+
+ <%= user.phone %>
+
+
+
+
+ <% if user.enabled? %>
+ check_circle <%= t :'ui.active' %>
+ <% else %>
+ cancel <%= t :'ui.inactive' %>
+ <% end %>
+
+ <%= audit_info user %>
+
+
+
+ <%= link_to 'edit', url_for(action: 'edit', id: user), class:"icon size--medium round", data: {turbo_action: 'advance'} %>
+
+
\ No newline at end of file
diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb
new file mode 100644
index 0000000..721b7d8
--- /dev/null
+++ b/app/views/admin/users/edit.html.erb
@@ -0,0 +1,19 @@
+<%= content_for :title, @user.name %>
+
+<%= turbo_frame_tag 'main' do %>
+
+ <%= turbo_stream.append 'flash', partial: 'layouts/flash' %>
+
+
+ <%= link_to t(:'users.list'), url_for(controller: 'users', action: 'index'), data: {turbo_action: 'advance'}, class: 'back-link' %>
+
+
+
+ <%= render "form", user: @user %>
+
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb
new file mode 100644
index 0000000..b96112b
--- /dev/null
+++ b/app/views/admin/users/index.html.erb
@@ -0,0 +1,41 @@
+<%= content_for :title, t(:'users.title') %>
+
+<%= turbo_frame_tag 'main' do %>
+
+ <%= turbo_stream.append 'flash', partial: 'layouts/flash' %>
+
+
+
<%= link_to yield(:title), params.permit(:sort, :reverse), class: 'list-title-link' %>
+
+
+ <%= render 'material/search' %>
+ <%= link_to tag.span(t(:'ui.new')), url_for(action: 'new'), class: 'btn add-btn', data: {turbo_action: 'advance'} %>
+
+
+
+
+
+
+
+
+
+
+ <%= render @users %>
+
+
+
+
+
+
+
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb
new file mode 100644
index 0000000..1f1db32
--- /dev/null
+++ b/app/views/admin/users/new.html.erb
@@ -0,0 +1,17 @@
+<%= content_for :title, t(:'users.new') %>
+
+<%= turbo_frame_tag 'main' do %>
+
+ <%= turbo_stream.append 'flash', partial: 'layouts/flash' %>
+
+
+ <%= link_to t(:'users.list'), url_for(controller: 'users', action: 'index'), data: {turbo_action: 'advance'}, class: 'back-link' %>
+
+
+
+ <%= render "form", user: @user %>
+
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/kaminari/_first_page.html.erb b/app/views/kaminari/_first_page.html.erb
new file mode 100644
index 0000000..0cc83b9
--- /dev/null
+++ b/app/views/kaminari/_first_page.html.erb
@@ -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
+-%>
+
+ <%= link_to_unless current_page.first?, t('views.pagination.first').html_safe, url, remote: remote %>
+
diff --git a/app/views/kaminari/_gap.html.erb b/app/views/kaminari/_gap.html.erb
new file mode 100644
index 0000000..bbb0f98
--- /dev/null
+++ b/app/views/kaminari/_gap.html.erb
@@ -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
+-%>
+<%= t('views.pagination.truncate').html_safe %>
diff --git a/app/views/kaminari/_last_page.html.erb b/app/views/kaminari/_last_page.html.erb
new file mode 100644
index 0000000..bc777b4
--- /dev/null
+++ b/app/views/kaminari/_last_page.html.erb
@@ -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
+-%>
+
+ <%= link_to_unless current_page.last?, t('views.pagination.last').html_safe, url, remote: remote %>
+
diff --git a/app/views/kaminari/_next_page.html.erb b/app/views/kaminari/_next_page.html.erb
new file mode 100644
index 0000000..3b0d054
--- /dev/null
+++ b/app/views/kaminari/_next_page.html.erb
@@ -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
+-%>
+
+ <%= link_to_unless current_page.last?, t('views.pagination.next').html_safe, url, rel: 'next', remote: remote %>
+
diff --git a/app/views/kaminari/_page.html.erb b/app/views/kaminari/_page.html.erb
new file mode 100644
index 0000000..393bfc4
--- /dev/null
+++ b/app/views/kaminari/_page.html.erb
@@ -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
+-%>
+
+ <%= link_to_unless page.current?, page, url, {remote: remote, rel: page.rel} %>
+
diff --git a/app/views/kaminari/_paginator.html.erb b/app/views/kaminari/_paginator.html.erb
new file mode 100644
index 0000000..183a16a
--- /dev/null
+++ b/app/views/kaminari/_paginator.html.erb
@@ -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 -%>
+
+<% end -%>
diff --git a/app/views/kaminari/_prev_page.html.erb b/app/views/kaminari/_prev_page.html.erb
new file mode 100644
index 0000000..0f32af4
--- /dev/null
+++ b/app/views/kaminari/_prev_page.html.erb
@@ -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
+-%>
+
+ <%= link_to_unless current_page.first?, t('views.pagination.previous').html_safe, url, rel: 'prev', remote: remote %>
+
diff --git a/app/views/layouts/_flash.html.erb b/app/views/layouts/_flash.html.erb
new file mode 100644
index 0000000..ea7d857
--- /dev/null
+++ b/app/views/layouts/_flash.html.erb
@@ -0,0 +1,5 @@
+ <% flash.each do |flash_type, message| %>
+
+ info <%= tag.span message %>
+
+ <% end %>
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
new file mode 100644
index 0000000..d94bc71
--- /dev/null
+++ b/app/views/layouts/admin.html.erb
@@ -0,0 +1,70 @@
+
+
+
+ <%= content_for?(:title) ? yield(:title) : ENV['PROJECT_NAME_LONG'] %>
+
+
+
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+
+ <%= stylesheet_link_tag "admin", "trix", "tom-select", "popup-menu", "forms", "lists", "assets", "nodes", "attachments" %>
+ <%= javascript_importmap_tags 'admin' %>
+
+
+
+
+ <% %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 %>
+ <%= t c, scope: 'icons' %>
+ <% end %>
+ <% end %>
+
+
+ <%= link_to svg('ikea-foundation-white'), root_url, class: 'logo', target: '_blank' %>
+
+
+ <% %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 %>
+
<%= t c, scope: 'icons' %>
+ <% end %>
+ <% end if current_user.admin_role? %>
+
+
+
+
+
+
+
+
+
+
+ <%= yield %>
+
+
+ <%= render 'layouts/flash' %>
+
+
+
+
\ No newline at end of file
diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb
index 3aac900..3868f9b 100644
--- a/app/views/layouts/mailer.html.erb
+++ b/app/views/layouts/mailer.html.erb
@@ -1,13 +1,121 @@
-
+
-
-
-
-
-
-
- <%= yield %>
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <%# link_to image_tag('den-hirschsprungske-samling.png', size: "180x58" , alt: I18n.t(:client_name)),
+ root_url, title: I18n.t(:client_name) %>
+
+ Two-factor authentication
+
+
+
+ <%= yield %>
+
+
+
+
+
+
+
+
+
+