feat: redesign Super Admin panel (#16137)
# Pull Request Template ## Description Fixes https://linear.app/chatwoot/issue/CW-8369/super-admin-redesign ## Type of change - [x] New feature (non-breaking change which adds functionality) ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
This commit is contained in:
@@ -2,4 +2,3 @@
|
||||
//= link administrate/application.css
|
||||
//= link administrate/application.js
|
||||
//= link administrate-field-active_storage/application.css
|
||||
//= link secretField.js
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// eslint-disable-next-line
|
||||
function toggleSecretField(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const toggler = e.currentTarget;
|
||||
const secretField = toggler.parentElement;
|
||||
const textElement = secretField.querySelector('[data-secret-masked]');
|
||||
|
||||
if (!textElement) return;
|
||||
|
||||
if (textElement.dataset.secretMasked === 'false') {
|
||||
const maskedLength = secretField.dataset.secretText?.length || 10;
|
||||
textElement.textContent = '•'.repeat(maskedLength);
|
||||
textElement.dataset.secretMasked = 'true';
|
||||
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
textElement.textContent = secretField.dataset.secretText;
|
||||
textElement.dataset.secretMasked = 'false';
|
||||
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-hide');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
function copySecretField(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const toggler = e.currentTarget;
|
||||
const secretField = toggler.parentElement;
|
||||
|
||||
navigator.clipboard.writeText(secretField.dataset.secretText);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.cell-data__secret-field').forEach(field => {
|
||||
const span = field.querySelector('[data-secret-masked]');
|
||||
if (span && span.dataset.secretMasked === 'true') {
|
||||
const len = field.dataset.secretText?.length || 10;
|
||||
span.textContent = '•'.repeat(len);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,5 @@
|
||||
@charset 'utf-8';
|
||||
|
||||
@import 'reset/normalize';
|
||||
|
||||
@import 'utilities/variables';
|
||||
@import 'utilities/text-color';
|
||||
|
||||
// The super admin is styled by app/javascript/entrypoints/superadmin.scss.
|
||||
// Sprockets only serves the selectize base styles its pickers need.
|
||||
@import 'selectize';
|
||||
|
||||
@import 'library/clearfix';
|
||||
@import 'library/data-label';
|
||||
@import 'library/variables';
|
||||
|
||||
@import 'base/forms';
|
||||
@import 'base/layout';
|
||||
@import 'base/lists';
|
||||
@import 'base/tables';
|
||||
@import 'base/typography';
|
||||
|
||||
@import 'components/app-container';
|
||||
@import 'components/attributes';
|
||||
@import 'components/buttons';
|
||||
@import 'components/cells';
|
||||
@import 'components/field-unit';
|
||||
@import 'components/flashes';
|
||||
@import 'components/form-actions';
|
||||
@import 'components/main-content';
|
||||
@import 'components/pagination';
|
||||
@import 'components/search';
|
||||
@import 'components/reports';
|
||||
|
||||
@import 'custom_styles';
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
fieldset {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
font-weight: $font-weight-medium;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: $font-weight-medium;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
display: block;
|
||||
font-family: $base-font-family;
|
||||
font-size: $base-font-size;
|
||||
}
|
||||
|
||||
[type="color"],
|
||||
[type="date"],
|
||||
[type="datetime-local"],
|
||||
[type="email"],
|
||||
[type="month"],
|
||||
[type="number"],
|
||||
[type="password"],
|
||||
[type="search"],
|
||||
[type="tel"],
|
||||
[type="text"],
|
||||
[type="time"],
|
||||
[type="url"],
|
||||
[type="week"],
|
||||
input:not([type]),
|
||||
textarea {
|
||||
appearance: none;
|
||||
background-color: $white;
|
||||
border: $base-border;
|
||||
border-radius: $base-border-radius;
|
||||
font-family: $base-font-family;
|
||||
padding: 0.5em;
|
||||
transition: border-color $base-duration $base-timing;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
border-color: mix($black, $base-border-color, 20%);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: $action-color;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: mix($black, $white, 5%);
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
border: $base-border;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
display: inline;
|
||||
margin-right: $small-spacing / 2;
|
||||
}
|
||||
|
||||
[type="file"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[type="checkbox"],
|
||||
[type="radio"],
|
||||
[type="file"],
|
||||
select {
|
||||
&:focus {
|
||||
outline: $focus-outline;
|
||||
outline-offset: $focus-outline-offset;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
html {
|
||||
background-color: $color-white;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
img,
|
||||
picture {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
ul,
|
||||
ol {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin-bottom: $small-spacing;
|
||||
|
||||
dt {
|
||||
font-weight: $font-weight-medium;
|
||||
margin-top: $small-spacing;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
font-size: $font-size-default;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: $base-border;
|
||||
|
||||
th {
|
||||
font-weight: $font-weight-medium;
|
||||
|
||||
&.cell-label--avatar-field {
|
||||
a {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
&:hover {
|
||||
background-color: $base-background-color;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: $focus-outline;
|
||||
outline-offset: -($focus-outline-width);
|
||||
}
|
||||
|
||||
td {
|
||||
&.cell-data--avatar-field {
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
|
||||
img {
|
||||
border-radius: 50%;
|
||||
height: $space-large;
|
||||
max-height: $space-large;
|
||||
width: $space-large;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
padding: $space-slab;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
td:first-child,
|
||||
th:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
td:last-child,
|
||||
th:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
td img {
|
||||
max-height: 2rem;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
body {
|
||||
color: $base-font-color;
|
||||
font-family: $base-font-family;
|
||||
font-size: $base-font-size;
|
||||
line-height: $base-line-height;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: $heading-font-family;
|
||||
font-size: $base-font-size;
|
||||
line-height: $heading-line-height;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 $small-spacing;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $action-color;
|
||||
transition: color $base-duration $base-timing;
|
||||
|
||||
&:hover {
|
||||
color: mix($black, $action-color, 25%);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: $focus-outline;
|
||||
outline-offset: $focus-outline-offset;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
border-bottom: $base-border;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
border-top: 0;
|
||||
margin: $base-spacing 0;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
.app-container {
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 100rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
.attribute-label {
|
||||
@include data-label;
|
||||
clear: left;
|
||||
float: left;
|
||||
margin-bottom: $base-spacing;
|
||||
margin-top: 0.25em;
|
||||
text-align: left;
|
||||
width: calc(16% - 1rem);
|
||||
}
|
||||
|
||||
.preserve-whitespace {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.attribute-data {
|
||||
float: left;
|
||||
margin-bottom: $base-spacing;
|
||||
margin-left: 1.25rem;
|
||||
width: calc(84% - 0.625rem);
|
||||
}
|
||||
|
||||
.attribute--nested {
|
||||
border: $base-border;
|
||||
padding: $small-spacing;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
button:not(.reset-base),
|
||||
input[type='button']:not(.reset-base),
|
||||
input[type='reset']:not(.reset-base),
|
||||
input[type='submit']:not(.reset-base),
|
||||
.button:not(.reset-base) {
|
||||
appearance: none;
|
||||
background-color: $color-woot;
|
||||
border: 0;
|
||||
border-radius: $base-border-radius;
|
||||
color: $white;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-size: $font-size-small;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-weight: $font-weight-medium;
|
||||
line-height: 1;
|
||||
padding: $space-one $space-two;
|
||||
text-decoration: none;
|
||||
transition: background-color $base-duration $base-timing;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background-color: mix($black, $color-woot, 20%);
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: $focus-outline;
|
||||
outline-offset: $focus-outline-offset;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
background-color: $color-woot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button--alt {
|
||||
background-color: transparent;
|
||||
border: $base-border;
|
||||
border-color: $blue;
|
||||
color: $blue;
|
||||
margin-bottom: $base-spacing;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
.cell-label {
|
||||
&:hover {
|
||||
a {
|
||||
color: $action-color;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: $action-color;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
display: inline-block;
|
||||
transition: color $base-duration $base-timing;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-label--asc,
|
||||
.cell-label--desc {
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.cell-label__sort-indicator {
|
||||
float: right;
|
||||
margin-left: 5px;
|
||||
|
||||
svg {
|
||||
fill: $hint-grey;
|
||||
height: 13px;
|
||||
transition: transform $base-duration $base-timing;
|
||||
width: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-label__sort-indicator--desc {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.cell-data--number,
|
||||
.cell-label--number {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cell-data__secret-field {
|
||||
align-items: center;
|
||||
color: $hint-grey;
|
||||
display: flex;
|
||||
|
||||
span {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
[data-secret-toggler],
|
||||
[data-secret-copier] {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0;
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
.field-unit {
|
||||
@include administrate-clearfix;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-bottom: $base-spacing;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-unit__label {
|
||||
float: left;
|
||||
margin-left: 0.625rem;
|
||||
text-align: right;
|
||||
width: calc(15% - 0.625rem);
|
||||
}
|
||||
|
||||
.field-unit__field {
|
||||
float: left;
|
||||
margin-left: 1.25rem;
|
||||
max-width: 31.15rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-unit--nested {
|
||||
border: $base-border;
|
||||
margin-left: 7.5%;
|
||||
max-width: 37.5rem;
|
||||
padding: $small-spacing;
|
||||
width: 100%;
|
||||
|
||||
.field-unit__field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-unit__label {
|
||||
width: 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
.field-unit--required {
|
||||
label::after {
|
||||
color: $red;
|
||||
content: ' *';
|
||||
}
|
||||
}
|
||||
|
||||
.attribute-data--avatar-field {
|
||||
height: $space-larger;
|
||||
width: $space-larger;
|
||||
|
||||
img {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
$base-spacing: 1.5em !default;
|
||||
$flashes: (
|
||||
"alert": #fff6bf,
|
||||
"error": #fbe3e4,
|
||||
"notice": #e5edf8,
|
||||
"success": #e6efc2,
|
||||
) !default;
|
||||
|
||||
@each $flash-type, $color in $flashes {
|
||||
.flash-#{$flash-type} {
|
||||
background-color: $color;
|
||||
color: mix($black, $color, 60%);
|
||||
display: block;
|
||||
margin-bottom: $base-spacing / 2;
|
||||
padding: $base-spacing / 2;
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
color: mix($black, $color, 70%);
|
||||
text-decoration: underline;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: mix($black, $color, 90%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.form-actions {
|
||||
margin-left: calc(15% + 1.25rem);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
.main-content {
|
||||
font-size: $font-size-default;
|
||||
left: 21rem;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.main-content__body {
|
||||
font-size: $font-size-small;
|
||||
padding: $space-two;
|
||||
|
||||
table {
|
||||
font-size: $font-size-small;
|
||||
}
|
||||
|
||||
form {
|
||||
margin-top: $space-two;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content__header {
|
||||
align-items: center;
|
||||
background-color: $color-white;
|
||||
border-bottom: 1px solid $color-border;
|
||||
display: flex;
|
||||
min-height: 3.5rem;
|
||||
padding: $space-small $space-normal;
|
||||
}
|
||||
|
||||
.main-content__page-title {
|
||||
font-size: $font-size-medium;
|
||||
font-weight: $font-weight-medium;
|
||||
margin-right: auto;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
.pagination {
|
||||
font-size: $font-size-default;
|
||||
margin-top: $base-spacing;
|
||||
padding-left: $base-spacing;
|
||||
padding-right: $base-spacing;
|
||||
text-align: center;
|
||||
|
||||
.first,
|
||||
.prev,
|
||||
.page,
|
||||
.next,
|
||||
.last {
|
||||
margin: $small-spacing;
|
||||
}
|
||||
|
||||
.current {
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
.report--list {
|
||||
display: flex;
|
||||
padding: 0 $space-two $space-larger;
|
||||
}
|
||||
|
||||
.report-card {
|
||||
flex: 1;
|
||||
font-size: $font-size-small;
|
||||
text-align: center;
|
||||
|
||||
.metric {
|
||||
font-size: $font-size-bigger;
|
||||
font-weight: 200;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
.search {
|
||||
margin-left: auto;
|
||||
margin-right: 1.25rem;
|
||||
max-width: 27.5rem;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search__input {
|
||||
background: $grey-1;
|
||||
padding-left: $space-normal * 2.5;
|
||||
padding-right: $space-normal * 2.5;
|
||||
}
|
||||
|
||||
.search__eyeglass-icon {
|
||||
fill: $grey-7;
|
||||
height: $space-normal;
|
||||
left: $space-normal;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: $space-normal;
|
||||
}
|
||||
|
||||
.search__clear-link {
|
||||
height: $space-normal;
|
||||
position: absolute;
|
||||
right: $space-normal * 0.75;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: $space-normal;
|
||||
}
|
||||
|
||||
.search__clear-icon {
|
||||
fill: $grey-5;
|
||||
height: $space-normal;
|
||||
position: absolute;
|
||||
transition: fill $base-duration $base-timing;
|
||||
width: $space-normal;
|
||||
|
||||
&:hover {
|
||||
fill: $action-color;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// custom styles for the dashboard
|
||||
|
||||
.feature-cell {
|
||||
background: $color-extra-light-blue;
|
||||
border-radius: 10px;
|
||||
float: left;
|
||||
margin-left: 8px;
|
||||
margin-top: 6px;
|
||||
padding: 4px 12px;
|
||||
|
||||
.icon-container {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.value-container {
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.feature-container {
|
||||
max-width: 100rem;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
@mixin administrate-clearfix {
|
||||
&::after {
|
||||
clear: both;
|
||||
content: '';
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
@mixin data-label {
|
||||
color: $hint-grey;
|
||||
font-size: 0.8em;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.0357em;
|
||||
position: relative;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// Typography
|
||||
$base-font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
|
||||
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif !default;
|
||||
$heading-font-family: $base-font-family !default;
|
||||
|
||||
$base-font-size: 16px !default;
|
||||
|
||||
$base-line-height: 1.5 !default;
|
||||
$heading-line-height: 1.2 !default;
|
||||
|
||||
// Other Sizes
|
||||
$base-border-radius: 4px !default;
|
||||
$base-spacing: $base-line-height * 1em !default;
|
||||
$small-spacing: $base-spacing / 2 !default;
|
||||
|
||||
// Colors
|
||||
$white: #fff !default;
|
||||
$black: #000 !default;
|
||||
|
||||
$blue: #1f93ff !default;
|
||||
$red: #ff382d !default;
|
||||
$light-yellow: #ffc532 !default;
|
||||
$light-green: #44ce4b !default;
|
||||
|
||||
$grey-0: #f6f7f7 !default;
|
||||
$grey-1: #f0f4f5 !default;
|
||||
$grey-2: #cfd8dc !default;
|
||||
$grey-5: #adb5bd !default;
|
||||
$grey-7: #293f54 !default;
|
||||
|
||||
$hint-grey: #7b808c !default;
|
||||
|
||||
// Font Colors
|
||||
$base-font-color: $grey-7 !default;
|
||||
$action-color: $blue !default;
|
||||
|
||||
// Background Colors
|
||||
$base-background-color: $grey-0 !default;
|
||||
|
||||
// Focus
|
||||
$focus-outline-color: transparentize($action-color, 0.4);
|
||||
$focus-outline-width: 3px;
|
||||
$focus-outline: $focus-outline-width solid $focus-outline-color;
|
||||
$focus-outline-offset: 1px;
|
||||
|
||||
// Flash Colors
|
||||
$flash-colors: (
|
||||
alert: $light-yellow,
|
||||
error: $red,
|
||||
notice: mix($white, $blue, 50%),
|
||||
success: $light-green
|
||||
);
|
||||
|
||||
// Border
|
||||
$base-border-color: $grey-1 !default;
|
||||
$base-border: 1px solid $base-border-color !default;
|
||||
|
||||
// Transitions
|
||||
$base-duration: 250ms !default;
|
||||
$base-timing: ease-in-out !default;
|
||||
@@ -1,448 +0,0 @@
|
||||
/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */
|
||||
|
||||
/* Document
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Correct the line height in all browsers.
|
||||
* 2. Prevent adjustments of font size after orientation changes in
|
||||
* IE on Windows Phone and in iOS.
|
||||
*/
|
||||
|
||||
html {
|
||||
line-height: 1.15; /* 1 */
|
||||
-ms-text-size-adjust: 100%; /* 2 */
|
||||
-webkit-text-size-adjust: 100%; /* 2 */
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Sections
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the margin in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
*/
|
||||
|
||||
article,
|
||||
aside,
|
||||
footer,
|
||||
header,
|
||||
nav,
|
||||
section {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the font size and margin on `h1` elements within `section` and
|
||||
* `article` contexts in Chrome, Firefox, and Safari.
|
||||
*/
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
|
||||
/* Grouping content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
* 1. Add the correct display in IE.
|
||||
*/
|
||||
|
||||
figcaption,
|
||||
figure,
|
||||
main { /* 1 */
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct margin in IE 8.
|
||||
*/
|
||||
|
||||
figure {
|
||||
margin: 1em 40px;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in Firefox.
|
||||
* 2. Show the overflow in Edge and IE.
|
||||
*/
|
||||
|
||||
hr {
|
||||
box-sizing: content-box; /* 1 */
|
||||
height: 0; /* 1 */
|
||||
overflow: visible; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
pre {
|
||||
font-family: monospace, monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/* Text-level semantics
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Remove the gray background on active links in IE 10.
|
||||
* 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
|
||||
*/
|
||||
|
||||
a {
|
||||
background-color: transparent; /* 1 */
|
||||
-webkit-text-decoration-skip: objects; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Remove the bottom border in Chrome 57- and Firefox 39-.
|
||||
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
|
||||
*/
|
||||
|
||||
abbr[title] {
|
||||
border-bottom: none; /* 1 */
|
||||
text-decoration: underline; /* 2 */
|
||||
text-decoration: underline dotted; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent the duplicate application of `bolder` by the next rule in Safari 6.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font weight in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: monospace, monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font style in Android 4.3-.
|
||||
*/
|
||||
|
||||
dfn {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct background and color in IE 9-.
|
||||
*/
|
||||
|
||||
mark {
|
||||
background-color: #ff0;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent `sub` and `sup` elements from affecting the line height in
|
||||
* all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/* Embedded content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
*/
|
||||
|
||||
audio,
|
||||
video {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in iOS 4-7.
|
||||
*/
|
||||
|
||||
audio:not([controls]) {
|
||||
display: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the border on images inside links in IE 10-.
|
||||
*/
|
||||
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the overflow in IE.
|
||||
*/
|
||||
|
||||
svg:not(:root) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Forms
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Change the font styles in all browsers (opinionated).
|
||||
* 2. Remove the margin in Firefox and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: sans-serif; /* 1 */
|
||||
font-size: 100%; /* 1 */
|
||||
line-height: 1.15; /* 1 */
|
||||
margin: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the overflow in IE.
|
||||
* 1. Show the overflow in Edge.
|
||||
*/
|
||||
|
||||
button,
|
||||
input { /* 1 */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inheritance of text transform in Edge, Firefox, and IE.
|
||||
* 1. Remove the inheritance of text transform in Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select { /* 1 */
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
|
||||
* controls in Android 4.
|
||||
* 2. Correct the inability to style clickable types in iOS and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
html [type="button"], /* 1 */
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
-webkit-appearance: button; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner border and padding in Firefox.
|
||||
*/
|
||||
|
||||
button::-moz-focus-inner,
|
||||
[type="button"]::-moz-focus-inner,
|
||||
[type="reset"]::-moz-focus-inner,
|
||||
[type="submit"]::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the focus styles unset by the previous rule.
|
||||
*/
|
||||
|
||||
button:-moz-focusring,
|
||||
[type="button"]:-moz-focusring,
|
||||
[type="reset"]:-moz-focusring,
|
||||
[type="submit"]:-moz-focusring {
|
||||
outline: 1px dotted ButtonText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the padding in Firefox.
|
||||
*/
|
||||
|
||||
fieldset {
|
||||
padding: 0.35em 0.75em 0.625em;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the text wrapping in Edge and IE.
|
||||
* 2. Correct the color inheritance from `fieldset` elements in IE.
|
||||
* 3. Remove the padding so developers are not caught out when they zero out
|
||||
* `fieldset` elements in all browsers.
|
||||
*/
|
||||
|
||||
legend {
|
||||
box-sizing: border-box; /* 1 */
|
||||
color: inherit; /* 2 */
|
||||
display: table; /* 1 */
|
||||
max-width: 100%; /* 1 */
|
||||
padding: 0; /* 3 */
|
||||
white-space: normal; /* 1 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add the correct display in IE 9-.
|
||||
* 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
|
||||
*/
|
||||
|
||||
progress {
|
||||
display: inline-block; /* 1 */
|
||||
vertical-align: baseline; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the default vertical scrollbar in IE.
|
||||
*/
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in IE 10-.
|
||||
* 2. Remove the padding in IE 10-.
|
||||
*/
|
||||
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
box-sizing: border-box; /* 1 */
|
||||
padding: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the cursor style of increment and decrement buttons in Chrome.
|
||||
*/
|
||||
|
||||
[type="number"]::-webkit-inner-spin-button,
|
||||
[type="number"]::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the odd appearance in Chrome and Safari.
|
||||
* 2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
outline-offset: -2px; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
[type="search"]::-webkit-search-cancel-button,
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inability to style clickable types in iOS and Safari.
|
||||
* 2. Change font properties to `inherit` in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/* Interactive
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Add the correct display in IE 9-.
|
||||
* 1. Add the correct display in Edge, IE, and Firefox.
|
||||
*/
|
||||
|
||||
details, /* 1 */
|
||||
menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/*
|
||||
* Add the correct display in all browsers.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/* Scripting
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
*/
|
||||
|
||||
canvas {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE.
|
||||
*/
|
||||
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hidden
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 10-.
|
||||
*/
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.text-color-red {
|
||||
color: $alert-color;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// Font sizes
|
||||
$font-size-nano: 0.5rem;
|
||||
$font-size-micro: 0.675rem;
|
||||
$font-size-mini: 0.75rem;
|
||||
$font-size-small: 0.875rem;
|
||||
$font-size-default: 1rem;
|
||||
$font-size-medium: 1.125rem;
|
||||
$font-size-large: 1.375rem;
|
||||
$font-size-big: 1.5rem;
|
||||
$font-size-bigger: 1.75rem;
|
||||
$font-size-mega: 2.125rem;
|
||||
$font-size-giga: 2.5rem;
|
||||
|
||||
// spaces
|
||||
$zero: 0;
|
||||
$space-micro: 0.125rem;
|
||||
$space-smaller: 0.25rem;
|
||||
$space-small: 0.5rem;
|
||||
$space-one: 0.675rem;
|
||||
$space-slab: 0.75rem;
|
||||
$space-normal: 1rem;
|
||||
$space-two: 1.25rem;
|
||||
$space-medium: 1.5rem;
|
||||
$space-large: 2rem;
|
||||
$space-larger: 3rem;
|
||||
$space-jumbo: 4rem;
|
||||
$space-mega: 6.25rem;
|
||||
|
||||
// font-weight
|
||||
$font-weight-feather: 100;
|
||||
$font-weight-light: 300;
|
||||
$font-weight-normal: 400;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-bold: 600;
|
||||
$font-weight-black: 700;
|
||||
|
||||
//Navbar
|
||||
$nav-bar-width: 23rem;
|
||||
$header-height: 5.6rem;
|
||||
|
||||
$woot-logo-padding: $space-large $space-two;
|
||||
|
||||
// Colors
|
||||
$color-woot: #1f93ff;
|
||||
$color-gray: #6e6f73;
|
||||
$color-light-gray: #747677;
|
||||
$color-border: #e0e6ed;
|
||||
$color-border-light: #f0f4f5;
|
||||
$color-background: #f4f6fb;
|
||||
$color-border-dark: #cad0d4;
|
||||
$color-background-light: #f9fafc;
|
||||
$color-white: #fff;
|
||||
$color-body: #3c4858;
|
||||
$color-heading: #1f2d3d;
|
||||
$color-extra-light-blue: #f5f7f9;
|
||||
|
||||
$primary-color: $color-woot;
|
||||
$secondary-color: #5d7592;
|
||||
$success-color: #44ce4b;
|
||||
$warning-color: #ffc532;
|
||||
$alert-color: #ff382d;
|
||||
|
||||
$masked-bg: rgba(0, 0, 0, .4);
|
||||
|
||||
// Color-palettes
|
||||
|
||||
$color-primary-light: #c7e3ff;
|
||||
$color-primary-dark: darken($color-woot, 20%);
|
||||
|
||||
// Thumbnail
|
||||
$thumbnail-radius: 4rem;
|
||||
|
||||
// chat-header
|
||||
$conv-header-height: 4rem;
|
||||
|
||||
// Inbox List
|
||||
|
||||
$inbox-thumb-size: 4.8rem;
|
||||
|
||||
|
||||
// Snackbar default
|
||||
$woot-snackbar-bg: #323232;
|
||||
$woot-snackbar-button: #ffeb3b;
|
||||
|
||||
$swift-ease-out-duration: .4s !default;
|
||||
$swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default;
|
||||
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default;
|
||||
|
||||
// Transitions
|
||||
$transition-ease-in: all 0.250s ease-in;
|
||||
@@ -39,11 +39,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
errors.concat(i.errors.full_messages) unless i.save
|
||||
end
|
||||
|
||||
if errors.any?
|
||||
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
|
||||
else
|
||||
redirect_to super_admin_settings_path, flash: success_flash
|
||||
end
|
||||
redirect_to super_admin_app_config_path(config: @config), flash: errors.any? ? { alert: errors.join(', ') } : success_flash
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
class SuperAdmin::ApplicationController < Administrate::ApplicationController
|
||||
include ActionView::Helpers::TagHelper
|
||||
include ActionView::Context
|
||||
include SuperAdmin::NavigationHelper
|
||||
|
||||
helper_method :render_vue_component, :settings_open?, :settings_pages
|
||||
helper SuperAdmin::NavigationHelper, SuperAdmin::CommandBarHelper, SuperAdmin::UiHelper
|
||||
helper_method :render_vue_component
|
||||
# authenticiation done via devise : SuperAdmin Model
|
||||
before_action :authenticate_super_admin!
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@ class PlatformBannerDashboard < Administrate::BaseDashboard
|
||||
SHOW_PAGE_ATTRIBUTES = %i[id banner_type title banner_message video_url active created_at updated_at].freeze
|
||||
FORM_ATTRIBUTES = %i[banner_type title banner_message video_url active].freeze
|
||||
|
||||
COLLECTION_FILTERS = {
|
||||
info: ->(resources) { resources.where(banner_type: :info) },
|
||||
warning: ->(resources) { resources.where(banner_type: :warning) },
|
||||
error: ->(resources) { resources.where(banner_type: :error) },
|
||||
feature_announcement: ->(resources) { resources.where(banner_type: :feature_announcement) }
|
||||
}.freeze
|
||||
|
||||
def display_resource(platform_banner)
|
||||
"Banner ##{platform_banner.id} (#{platform_banner.banner_type})"
|
||||
end
|
||||
|
||||
@@ -36,8 +36,7 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
updated_at: Field::DateTime,
|
||||
pubsub_token: Field::String,
|
||||
type: Field::Select.with_options(collection: [nil, 'SuperAdmin']),
|
||||
accounts: CountField,
|
||||
access_token: Field::HasOne
|
||||
accounts: CountField
|
||||
}.freeze
|
||||
|
||||
# COLLECTION_ATTRIBUTES
|
||||
@@ -68,7 +67,6 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
updated_at
|
||||
confirmed_at
|
||||
account_users
|
||||
access_token
|
||||
].freeze
|
||||
|
||||
# FORM_ATTRIBUTES
|
||||
|
||||
70
app/helpers/super_admin/account_feature_icons.yml
Normal file
70
app/helpers/super_admin/account_feature_icons.yml
Normal file
@@ -0,0 +1,70 @@
|
||||
# Icon for each account feature flag in config/features.yml, shown on the account's Features tab.
|
||||
# Tailwind scans this file: every class must be a literal here.
|
||||
advanced_assignment: 'i-lucide-gauge'
|
||||
advanced_search: 'i-lucide-scan-search'
|
||||
advanced_search_indexing: 'i-lucide-database-zap'
|
||||
agent_bots: 'i-lucide-bot'
|
||||
agent_management: 'i-lucide-square-user'
|
||||
api_and_webhooks: 'i-lucide-webhook'
|
||||
assignment_v2: 'i-lucide-user-cog'
|
||||
audit_log_ip_address: 'i-lucide-map-pinned'
|
||||
audit_logs: 'i-lucide-scroll-text'
|
||||
auto_resolve_conversations: 'i-lucide-timer-reset'
|
||||
automations: 'i-lucide-repeat'
|
||||
branded_email_templates: 'i-lucide-palette'
|
||||
campaign_analytics: 'i-lucide-chart-no-axes-combined'
|
||||
campaigns: 'i-lucide-megaphone'
|
||||
canned_responses: 'i-lucide-message-square-quote'
|
||||
captain_classifier: 'i-lucide-brain-circuit'
|
||||
captain_document_auto_sync: 'i-lucide-refresh-cw'
|
||||
captain_integration: 'i-woot-captain'
|
||||
captain_tasks: 'i-lucide-list-checks'
|
||||
channel_email: 'i-lucide-mail'
|
||||
channel_facebook: 'i-logos-messenger'
|
||||
channel_instagram: 'i-logos-instagram-icon dark:invert'
|
||||
channel_tiktok: 'i-logos-tiktok-icon dark:invert'
|
||||
channel_voice: 'i-lucide-phone-call'
|
||||
channel_website: 'i-lucide-message-circle'
|
||||
chatwoot_v4: 'i-lucide-rocket'
|
||||
companies: 'i-lucide-building-2'
|
||||
company_enrichment: 'i-lucide-sparkles'
|
||||
contact_chatwoot_support_team: 'i-lucide-life-buoy'
|
||||
conversation_monitors: 'i-lucide-activity'
|
||||
conversation_required_attributes: 'i-lucide-clipboard-list'
|
||||
conversation_unread_counts: 'i-lucide-bell-dot'
|
||||
crm: 'i-lucide-contact'
|
||||
crm_integration: 'i-lucide-link'
|
||||
crm_v2: 'i-lucide-book-user'
|
||||
csat_review_notes: 'i-lucide-star'
|
||||
custom_attributes: 'i-lucide-code'
|
||||
custom_reply_domain: 'i-lucide-globe'
|
||||
custom_reply_email: 'i-lucide-at-sign'
|
||||
custom_roles: 'i-lucide-shield-plus'
|
||||
custom_tools: 'i-lucide-wrench'
|
||||
data_import: 'i-lucide-upload'
|
||||
delayed_automations: 'i-lucide-hourglass'
|
||||
disable_branding: 'i-lucide-eye-off'
|
||||
email_continuity_on_api_channel: 'i-lucide-reply'
|
||||
help_center: 'i-lucide-library-big'
|
||||
help_center_embedding_search: 'i-lucide-text-search'
|
||||
inbound_emails: 'i-lucide-mail-open'
|
||||
inbox_management: 'i-lucide-inbox'
|
||||
inbox_view: 'i-lucide-layout-list'
|
||||
integrations: 'i-lucide-blocks'
|
||||
ip_lookup: 'i-lucide-map-pin'
|
||||
labels: 'i-lucide-tags'
|
||||
linear_integration: 'i-logos-linear-icon dark:invert'
|
||||
macros: 'i-lucide-toy-brick'
|
||||
notion_integration: 'i-logos-notion-icon dark:invert'
|
||||
reply_mailer_migration: 'i-lucide-send'
|
||||
report_rollup: 'i-lucide-chart-column-stacked'
|
||||
reports: 'i-lucide-chart-spline'
|
||||
saml: 'i-lucide-key-round'
|
||||
search_with_gin: 'i-lucide-search'
|
||||
shopify_integration: 'i-logos-shopify'
|
||||
sla: 'i-lucide-clock-alert'
|
||||
team_management: 'i-lucide-users'
|
||||
unread_count_for_filters: 'i-lucide-list-filter'
|
||||
voice_recorder: 'i-lucide-mic'
|
||||
whatsapp_embedded_signup_inbox_creation: 'i-logos-whatsapp-icon'
|
||||
whatsapp_manual_transfer: 'i-logos-whatsapp-icon'
|
||||
@@ -7,6 +7,17 @@ module SuperAdmin::AccountFeaturesHelper
|
||||
account_features.filter { |feature| feature['premium'] }.pluck('name')
|
||||
end
|
||||
|
||||
# The list the dashboard shows its "Learn more" links from (dashboard/helper/featureHelper.js).
|
||||
def self.feature_help_urls
|
||||
YAML.safe_load(Rails.root.join('config/feature_help_urls.yml').read).freeze
|
||||
end
|
||||
|
||||
def self.feature_icons
|
||||
icons = YAML.safe_load(Rails.root.join('app/helpers/super_admin/account_feature_icons.yml').read)
|
||||
icons.default = 'i-lucide-toggle-right'
|
||||
icons.freeze
|
||||
end
|
||||
|
||||
# Returns a hash mapping feature names to their display names
|
||||
def self.feature_display_names
|
||||
account_features.each_with_object({}) do |feature, hash|
|
||||
@@ -37,16 +48,12 @@ module SuperAdmin::AccountFeaturesHelper
|
||||
filtered = filter_deprecated_features(filtered)
|
||||
display_names = feature_display_names
|
||||
|
||||
regular, premium = filtered.partition { |key, _value| account_premium_features.exclude?(key) }
|
||||
premium_features = account_premium_features
|
||||
regular, premium = filtered.partition { |key, _value| premium_features.exclude?(key) }
|
||||
|
||||
[
|
||||
sort_and_transform_features(regular, display_names),
|
||||
sort_and_transform_features(premium, display_names)
|
||||
]
|
||||
end
|
||||
|
||||
def self.filtered_features(features)
|
||||
regular, premium = partition_features(features)
|
||||
regular.merge(premium)
|
||||
end
|
||||
end
|
||||
|
||||
70
app/helpers/super_admin/command_bar_helper.rb
Normal file
70
app/helpers/super_admin/command_bar_helper.rb
Normal file
@@ -0,0 +1,70 @@
|
||||
module SuperAdmin::CommandBarHelper
|
||||
def command_groups
|
||||
[
|
||||
{ label: t('super_admin.command_bar.go_to'), commands: primary_nav_items },
|
||||
{ label: t('super_admin.command_bar.create'), commands: create_commands },
|
||||
{ label: t('super_admin.navigation.settings'), commands: settings_nav_items },
|
||||
{ label: t('super_admin.navigation.monitor'), commands: monitor_nav_items },
|
||||
{ label: t('super_admin.navigation.theme.label'), commands: theme_commands },
|
||||
{ label: t('super_admin.navigation.account'), commands: session_commands }
|
||||
]
|
||||
end
|
||||
|
||||
def record_commands(record)
|
||||
commands = [
|
||||
{ action: :show, label: t('super_admin.command_bar.open'), url: [namespace, record], icon: 'i-lucide-arrow-right' },
|
||||
{ action: :edit, label: t('administrate.actions.edit'), url: [:edit, namespace, record], icon: 'i-lucide-pencil' }
|
||||
].select { |command| accessible_action?(record, command[:action]) && !current_page?(command[:url]) }
|
||||
commands += user_commands(record) if record.is_a?(User)
|
||||
commands += account_commands(record) if record.is_a?(Account)
|
||||
return commands unless accessible_action?(record, :destroy)
|
||||
|
||||
commands << { label: t('administrate.actions.destroy'), url: [namespace, record], method: :delete, icon: 'i-lucide-trash-2', danger: true,
|
||||
form: { data: { confirm: t('administrate.actions.confirm') } } }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_commands
|
||||
nav_resources.select { |resource| existing_action?(resource, :new) }.map do |resource|
|
||||
{ label: t('administrate.actions.new_resource', name: resource.singularize.titleize.downcase),
|
||||
url: [:new, namespace, resource.singularize.to_sym], icon: 'i-lucide-plus' }
|
||||
end
|
||||
end
|
||||
|
||||
def theme_commands
|
||||
[
|
||||
{ label: t('super_admin.navigation.theme.system'), icon: 'i-lucide-monitor', data: { theme_value: 'system' } },
|
||||
{ label: t('super_admin.navigation.theme.light'), icon: 'i-lucide-sun', data: { theme_value: 'light' } },
|
||||
{ label: t('super_admin.navigation.theme.dark'), icon: 'i-lucide-moon', data: { theme_value: 'dark' } }
|
||||
]
|
||||
end
|
||||
|
||||
def session_commands
|
||||
[
|
||||
{ label: t('super_admin.navigation.profile'), url: super_admin_user_path(current_super_admin), icon: 'i-lucide-user' },
|
||||
{ label: t('super_admin.navigation.agent_dashboard'), url: '/', icon: 'i-lucide-message-circle' },
|
||||
{ label: t('super_admin.navigation.logout'), url: super_admin_logout_path, icon: 'i-lucide-log-out' }
|
||||
]
|
||||
end
|
||||
|
||||
def user_commands(user)
|
||||
commands = []
|
||||
if current_page?([namespace, user])
|
||||
commands << { label: t('super_admin.users.impersonate'), icon: 'i-lucide-venetian-mask', data: { dialog_open: 'impersonate-dialog' } }
|
||||
end
|
||||
return commands if user.confirmed?
|
||||
|
||||
commands << { label: t('super_admin.users.resend_confirmation.button'), url: resend_confirmation_super_admin_user_path(user), method: :post,
|
||||
icon: 'i-lucide-mail-check' }
|
||||
end
|
||||
|
||||
def account_commands(account)
|
||||
commands = [{ label: t('super_admin.accounts.reset_cache'), url: reset_cache_super_admin_account_path(account), method: :post,
|
||||
icon: 'i-lucide-refresh-cw' }]
|
||||
return commands unless Seeders::AccountSeeder.allowed?
|
||||
|
||||
commands << { label: t('super_admin.accounts.seed_data'), url: seed_super_admin_account_path(account), method: :post, icon: 'i-lucide-sprout',
|
||||
danger: true, form: { data: { confirm: t('super_admin.accounts.seed_data_warning') } } }
|
||||
end
|
||||
end
|
||||
@@ -6,39 +6,39 @@ saml:
|
||||
name: 'SAML SSO'
|
||||
description: 'Configuration for controlling SAML Single Sign-On availability'
|
||||
enabled: <%= ChatwootApp.enterprise? %>
|
||||
icon: 'icon-lock-line'
|
||||
icon: 'i-lucide-key-round'
|
||||
config_key: 'saml'
|
||||
enterprise: true
|
||||
custom_branding:
|
||||
name: 'Custom Branding'
|
||||
description: 'Apply your own branding to this installation.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-paint-brush-line'
|
||||
icon: 'i-lucide-palette'
|
||||
config_key: 'custom_branding'
|
||||
enterprise: true
|
||||
agent_capacity:
|
||||
name: 'Agent Capacity'
|
||||
description: 'Set limits to auto-assigning conversations to your agents.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-hourglass-line'
|
||||
icon: 'i-lucide-gauge'
|
||||
enterprise: true
|
||||
audit_logs:
|
||||
name: 'Audit Logs'
|
||||
description: 'Track and trace account activities with ease with detailed audit logs.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-menu-search-line'
|
||||
icon: 'i-lucide-scroll-text'
|
||||
enterprise: true
|
||||
disable_branding:
|
||||
name: 'Disable Branding'
|
||||
description: 'Disable branding on live-chat widget and external emails.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-sailbot-fill'
|
||||
icon: 'i-lucide-eye-off'
|
||||
enterprise: true
|
||||
voice_calls:
|
||||
name: 'Voice Calls'
|
||||
description: 'Enable voice calling capabilities for your agents and customers.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-voice-line'
|
||||
icon: 'i-lucide-phone-call'
|
||||
enterprise: true
|
||||
|
||||
# ------- Product Features ------- #
|
||||
@@ -46,12 +46,12 @@ help_center:
|
||||
name: 'Help Center'
|
||||
description: 'Allow agents to create help center articles and publish them in a portal.'
|
||||
enabled: true
|
||||
icon: 'icon-book-2-line'
|
||||
icon: 'i-lucide-book-open'
|
||||
captain:
|
||||
name: 'Captain'
|
||||
description: 'Enable AI-powered conversations with your customers.'
|
||||
enabled: true
|
||||
icon: 'icon-captain'
|
||||
icon: 'i-woot-captain text-n-violet-9'
|
||||
config_key: 'captain'
|
||||
|
||||
# ------- Communication Channels ------- #
|
||||
@@ -59,64 +59,64 @@ live_chat:
|
||||
name: 'Live Chat'
|
||||
description: 'Improve your customer experience using a live chat on your website.'
|
||||
enabled: true
|
||||
icon: 'icon-chat-smile-3-line'
|
||||
icon: 'i-lucide-message-circle'
|
||||
email:
|
||||
name: 'Email'
|
||||
description: 'Manage your email customer interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-mail-send-fill'
|
||||
icon: 'i-lucide-mail'
|
||||
config_key: 'email'
|
||||
sms:
|
||||
name: 'SMS'
|
||||
description: 'Manage your SMS customer interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-message-line'
|
||||
icon: 'i-lucide-message-square-text'
|
||||
messenger:
|
||||
name: 'Messenger'
|
||||
description: 'Stay connected with your customers on Facebook & Instagram.'
|
||||
enabled: true
|
||||
icon: 'icon-messenger-line'
|
||||
icon: 'i-logos-messenger'
|
||||
config_key: 'facebook'
|
||||
instagram:
|
||||
name: 'Instagram'
|
||||
description: 'Stay connected with your customers on Instagram'
|
||||
enabled: true
|
||||
icon: 'icon-instagram'
|
||||
icon: 'i-logos-instagram-icon dark:invert'
|
||||
config_key: 'instagram'
|
||||
tiktok:
|
||||
name: 'TikTok'
|
||||
description: 'Stay connected with your customers on TikTok'
|
||||
enabled: true
|
||||
icon: 'icon-tiktok'
|
||||
icon: 'i-logos-tiktok-icon dark:invert'
|
||||
config_key: 'tiktok'
|
||||
whatsapp:
|
||||
name: 'WhatsApp'
|
||||
description: 'Manage your WhatsApp business interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-whatsapp-line'
|
||||
icon: 'i-logos-whatsapp-icon'
|
||||
telegram:
|
||||
name: 'Telegram'
|
||||
description: 'Manage your Telegram customer interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-telegram-line'
|
||||
icon: 'i-logos-telegram'
|
||||
line:
|
||||
name: 'Line'
|
||||
description: 'Manage your Line customer interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-line-line'
|
||||
icon: 'i-ri-line-fill text-[#06C755]'
|
||||
|
||||
# ------- OAuth & Authentication ------- #
|
||||
google:
|
||||
name: 'Google'
|
||||
description: 'Configuration for setting up Google OAuth Integration'
|
||||
enabled: true
|
||||
icon: 'icon-google'
|
||||
icon: 'i-logos-google-icon'
|
||||
config_key: 'google'
|
||||
microsoft:
|
||||
name: 'Microsoft'
|
||||
description: 'Configuration for setting up Microsoft Email'
|
||||
enabled: true
|
||||
icon: 'icon-microsoft'
|
||||
icon: 'i-logos-microsoft-icon'
|
||||
config_key: 'microsoft'
|
||||
|
||||
# ------- Third-party Integrations ------- #
|
||||
@@ -130,29 +130,29 @@ linear:
|
||||
name: 'Linear'
|
||||
description: 'Configuration for setting up Linear Integration'
|
||||
enabled: true
|
||||
icon: 'icon-linear'
|
||||
icon: 'i-logos-linear-icon dark:invert'
|
||||
config_key: 'linear'
|
||||
notion:
|
||||
name: 'Notion'
|
||||
description: 'Configuration for setting up Notion Integration'
|
||||
enabled: true
|
||||
icon: 'icon-notion'
|
||||
icon: 'i-logos-notion-icon dark:invert'
|
||||
config_key: 'notion'
|
||||
slack:
|
||||
name: 'Slack'
|
||||
description: 'Configuration for setting up Slack Integration'
|
||||
enabled: true
|
||||
icon: 'icon-slack'
|
||||
icon: 'i-logos-slack-icon'
|
||||
config_key: 'slack'
|
||||
whatsapp_embedded:
|
||||
name: 'WhatsApp Embedded'
|
||||
description: 'Configuration for setting up WhatsApp Embedded Integration'
|
||||
enabled: true
|
||||
icon: 'icon-whatsapp-line'
|
||||
icon: 'i-logos-whatsapp-icon'
|
||||
config_key: 'whatsapp_embedded'
|
||||
shopify:
|
||||
name: 'Shopify'
|
||||
description: 'Configuration for setting up Shopify Integration'
|
||||
enabled: true
|
||||
icon: 'icon-shopify'
|
||||
icon: 'i-logos-shopify'
|
||||
config_key: 'shopify'
|
||||
|
||||
@@ -8,9 +8,9 @@ module SuperAdmin::FeaturesHelper
|
||||
quantity = ChatwootHub.pricing_plan_quantity
|
||||
|
||||
if plan == 'premium'
|
||||
"You are currently on the <span class='font-semibold'>#{plan}</span> plan with <span class='font-semibold'>#{quantity} agents</span>."
|
||||
"You are currently on the <span class='font-semibold'>#{plan.titleize}</span> plan with <span class='font-semibold'>#{quantity} agents</span>."
|
||||
else
|
||||
"You are currently on the <span class='font-semibold'>#{plan}</span> edition plan."
|
||||
"You are currently on the <span class='font-semibold'>#{plan.titleize}</span> edition plan."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,16 +1,121 @@
|
||||
module SuperAdmin::NavigationHelper
|
||||
NAV_ICONS = {
|
||||
'accounts' => 'i-lucide-building-2',
|
||||
'users' => 'i-lucide-users',
|
||||
'agent_bots' => 'i-lucide-bot',
|
||||
'platform_apps' => 'i-lucide-blocks',
|
||||
'platform_banners' => 'i-lucide-megaphone'
|
||||
}.freeze
|
||||
|
||||
# Routed resources that are reached from other pages instead of the navigation.
|
||||
HIDDEN_RESOURCES = %w[dashboard access_tokens installation_configs].freeze
|
||||
|
||||
# Always in the tab bar on small screens; on phones the others sit behind "More".
|
||||
PINNED_RESOURCES = %w[accounts users].freeze
|
||||
|
||||
# Names short enough to sit under an icon in that tab bar.
|
||||
TAB_LABELS = {
|
||||
'agent_bots' => 'super_admin.navigation.tabs.agent_bots',
|
||||
'platform_apps' => 'super_admin.navigation.tabs.platform_apps',
|
||||
'platform_banners' => 'super_admin.navigation.tabs.platform_banners'
|
||||
}.freeze
|
||||
|
||||
# Heading each feature's page sits under in the settings list. Pages not listed stay at the top.
|
||||
SETTINGS_GROUPS = {
|
||||
'super_admin.navigation.settings_groups.product' => %w[saml custom_branding captain],
|
||||
'super_admin.navigation.settings_groups.channels' => %w[email messenger instagram tiktok],
|
||||
'super_admin.navigation.settings_groups.authentication' => %w[google microsoft],
|
||||
'super_admin.navigation.settings_groups.integrations' => %w[linear notion slack whatsapp_embedded shopify]
|
||||
}.freeze
|
||||
|
||||
def settings_open?
|
||||
params[:controller].in? %w[super_admin/settings super_admin/app_configs]
|
||||
end
|
||||
|
||||
def monitor_open?
|
||||
controller_name.in? %w[instance_statuses push_diagnostics]
|
||||
end
|
||||
|
||||
def settings_pages
|
||||
features = SuperAdmin::FeaturesHelper.available_features.select do |_feature, attrs|
|
||||
attrs['config_key'].present? && attrs['enabled']
|
||||
@settings_pages ||= begin
|
||||
features = SuperAdmin::FeaturesHelper.available_features.select do |_feature, attrs|
|
||||
attrs['config_key'].present? && attrs['enabled']
|
||||
end
|
||||
|
||||
pages = [['general', { 'config_key' => 'general', 'name' => t('super_admin.settings.pages.general'), 'icon' => 'i-lucide-sliders-horizontal' }]]
|
||||
if ChatwootApp.chatwoot_cloud?
|
||||
pages << ['internal', { 'config_key' => 'internal', 'name' => t('super_admin.settings.pages.internal'),
|
||||
'description' => t('super_admin.settings.pages.internal_description'), 'icon' => 'i-lucide-lock-keyhole' }]
|
||||
end
|
||||
|
||||
pages + features.to_a
|
||||
end
|
||||
end
|
||||
|
||||
# Add general at the beginning
|
||||
general_feature = [['general', { 'config_key' => 'general', 'name' => 'General' }]]
|
||||
def primary_nav_items
|
||||
instance = t('super_admin.navigation.groups.instance')
|
||||
[
|
||||
{ label: t('super_admin.navigation.dashboard'), url: super_admin_root_path, icon: 'i-lucide-layout-dashboard',
|
||||
active: current_page?(super_admin_root_path), pinned: true },
|
||||
*resource_nav_items,
|
||||
{ label: t('super_admin.navigation.settings'), url: super_admin_settings_path, icon: 'i-lucide-settings', active: settings_open?,
|
||||
pinned: true, group: instance },
|
||||
{ label: t('super_admin.navigation.monitor'), url: super_admin_instance_status_path, icon: 'i-lucide-activity', active: monitor_open?,
|
||||
group: instance }
|
||||
]
|
||||
end
|
||||
|
||||
general_feature + features.to_a
|
||||
def settings_nav_items(current_config = nil)
|
||||
overview = { label: t('super_admin.navigation.overview'), url: super_admin_settings_path, icon: 'i-lucide-layout-grid',
|
||||
active: controller_name == 'settings' }
|
||||
|
||||
[overview] + settings_pages.map do |feature_key, attrs|
|
||||
group_key = SETTINGS_GROUPS.keys.find { |key| SETTINGS_GROUPS[key].include?(feature_key) }
|
||||
{ label: attrs['name'], url: super_admin_app_config_path(config: attrs['config_key']), group: (t(group_key) if group_key),
|
||||
icon: attrs['icon'], active: attrs['config_key'] == current_config }
|
||||
end
|
||||
end
|
||||
|
||||
# Settings and Monitor swap the sidebar for their own links; nil on every other page.
|
||||
def section_nav
|
||||
if settings_open?
|
||||
current_config = params[:config] || 'general' if controller_name == 'app_configs'
|
||||
{ label: t('super_admin.navigation.settings'), items: settings_nav_items(current_config) }
|
||||
elsif monitor_open?
|
||||
{ label: t('super_admin.navigation.monitor'), items: monitor_nav_items }
|
||||
end
|
||||
end
|
||||
|
||||
def monitor_nav_items
|
||||
[
|
||||
{ label: t('super_admin.navigation.instance_health'), url: super_admin_instance_status_path, icon: 'i-lucide-heart-pulse',
|
||||
active: controller_name == 'instance_statuses' },
|
||||
{ label: t('super_admin.navigation.push_diagnostics'), url: super_admin_push_diagnostics_path, icon: 'i-lucide-bell-ring',
|
||||
active: controller_name == 'push_diagnostics' },
|
||||
{ label: t('super_admin.navigation.sidekiq_dashboard'), url: sidekiq_web_path, icon: 'i-lucide-layers', external: true }
|
||||
]
|
||||
end
|
||||
|
||||
def nav_resources
|
||||
Administrate::Namespace.new(namespace).resources_with_index_route.reject do |resource|
|
||||
HIDDEN_RESOURCES.include?(resource) || (resource == 'platform_banners' && !ChatwootApp.chatwoot_cloud?)
|
||||
end
|
||||
end
|
||||
|
||||
# superadmin.js keeps the tab last opened on a page in this cookie, so the page renders with that tab open.
|
||||
def active_tab(tabs)
|
||||
path, key = cookies[:super_admin_tab].to_s.split('#', 2)
|
||||
path == request.path && tabs.any? { |tab| tab[:key] == key } ? key : tabs.first[:key]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resource_nav_items
|
||||
nav_resources.map do |resource|
|
||||
tab_label_key = TAB_LABELS[resource]
|
||||
{ label: display_resource_name(resource), tab_label: (t(tab_label_key) if tab_label_key), url: resource_index_route(resource),
|
||||
icon: NAV_ICONS[resource], active: nav_link_state(resource) == :active,
|
||||
pinned: PINNED_RESOURCES.include?(resource), group: t('super_admin.navigation.groups.manage') }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
19
app/helpers/super_admin/ui_helper.rb
Normal file
19
app/helpers/super_admin/ui_helper.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
# Tailwind classes for form controls. Rails builds those tags (`f.text_field`, `select_tag`, `check_box_tag` and so on),
|
||||
# so the classes are passed to them; everything else is a partial. Tailwind scans this file: every class must be a literal here.
|
||||
module SuperAdmin::UiHelper
|
||||
INPUT = %w[
|
||||
block min-h-9 w-full rounded-lg border border-n-slate-6 bg-n-solid-1 px-3 py-1.5 text-body-main text-n-slate-12 outline-none
|
||||
transition-colors placeholder:text-n-slate-10 hover:border-n-slate-8 focus:border-n-brand focus:ring-2 focus:ring-n-brand/25
|
||||
disabled:cursor-not-allowed disabled:bg-n-slate-3 disabled:text-n-slate-10 touch:min-h-11 touch:text-base
|
||||
].join(' ').freeze
|
||||
|
||||
CLASSES = {
|
||||
input: INPUT,
|
||||
select: "#{INPUT} appearance-none bg-chevron pe-9",
|
||||
checkbox: 'size-4 shrink-0 cursor-pointer accent-n-brand disabled:cursor-not-allowed touch:size-5'
|
||||
}.freeze
|
||||
|
||||
def ui(name, extra = nil)
|
||||
class_names(CLASSES.fetch(name), extra)
|
||||
end
|
||||
end
|
||||
79
app/javascript/dashboard/assets/scss/_typography.scss
Normal file
79
app/javascript/dashboard/assets/scss/_typography.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
@layer utilities {
|
||||
// ============================================================================
|
||||
// TYPOGRAPHY UTILITIES
|
||||
// ============================================================================
|
||||
//
|
||||
// | Class | Use Case |
|
||||
// |--------------------|----------------------------------------------------|
|
||||
// | .text-body-main | <p>, <span>, general body text |
|
||||
// | .text-body-para | <p> for paragraphs, larger text blocks |
|
||||
// | .text-heading-1 | <h1>, page titles, panel headers |
|
||||
// | .text-heading-2 | <h2>, section headings, card titles |
|
||||
// | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
|
||||
// | .text-label | <label>, form labels, field names |
|
||||
// | .text-label-small | <small>, footnotes, tags, badges, captions |
|
||||
// | .text-button | <button>, standard button text |
|
||||
// | .text-button-small | <button>, small/compact button text |
|
||||
|
||||
// body-text-main: Main text style for general body text
|
||||
.text-body-main {
|
||||
@apply font-inter text-sm font-420;
|
||||
letter-spacing: -0.28px;
|
||||
line-height: 21px; // 150%
|
||||
}
|
||||
|
||||
// body-text-paragraph: For paragraphs or larger blocks of text
|
||||
.text-body-para {
|
||||
@apply font-inter text-sm font-420;
|
||||
letter-spacing: -0.21px;
|
||||
line-height: 21px; // 150%
|
||||
}
|
||||
|
||||
// heading-1: Large heading for pages and panels
|
||||
.text-heading-1 {
|
||||
@apply font-inter text-lg font-520;
|
||||
letter-spacing: -0.27px;
|
||||
line-height: 24px; // 133.333%
|
||||
}
|
||||
|
||||
// heading-2: Secondary heading for sections
|
||||
.text-heading-2 {
|
||||
@apply font-inter text-base font-medium;
|
||||
letter-spacing: -0.27px;
|
||||
line-height: 24px; // 133.333%
|
||||
}
|
||||
|
||||
// heading-3: For card headings, breadcrumbs, subsections
|
||||
.text-heading-3 {
|
||||
@apply font-inter text-sm font-medium;
|
||||
letter-spacing: -0.27px;
|
||||
line-height: 21px; // 150%
|
||||
}
|
||||
|
||||
// label: Standard label text for form fields
|
||||
.text-label {
|
||||
@apply font-inter text-sm font-medium;
|
||||
line-height: 21px; // 150%
|
||||
}
|
||||
|
||||
// label-small: Smallest font for labels, footnotes, tags
|
||||
.text-label-small {
|
||||
@apply font-inter text-xs font-440;
|
||||
letter-spacing: -0.24px;
|
||||
line-height: 16px; // 133.333%
|
||||
}
|
||||
|
||||
// button-text: Text for standard size buttons
|
||||
.text-button {
|
||||
@apply font-inter text-sm font-460;
|
||||
letter-spacing: -0.28px;
|
||||
line-height: 21px; // 150%
|
||||
}
|
||||
|
||||
// button-text-small: Text for smaller buttons
|
||||
.text-button-small {
|
||||
@apply font-inter text-xs font-440;
|
||||
letter-spacing: -0.24px;
|
||||
line-height: 18px; // 150%
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
@import 'shared/assets/fonts/InterDisplay/inter-display';
|
||||
@import 'shared/assets/fonts/inter';
|
||||
|
||||
// Typography Utilities
|
||||
@import 'typography';
|
||||
|
||||
// Next Colors
|
||||
@import 'next-colors';
|
||||
|
||||
@@ -65,84 +68,4 @@ body {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* TYPOGRAPHY UTILITIES
|
||||
* ============================================================================
|
||||
*
|
||||
* | Class | Use Case |
|
||||
* |--------------------|----------------------------------------------------|
|
||||
* | .text-body-main | <p>, <span>, general body text |
|
||||
* | .text-body-para | <p> for paragraphs, larger text blocks |
|
||||
* | .text-heading-1 | <h1>, page titles, panel headers |
|
||||
* | .text-heading-2 | <h2>, section headings, card titles |
|
||||
* | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
|
||||
* | .text-label | <label>, form labels, field names |
|
||||
* | .text-label-small | <small>, footnotes, tags, badges, captions |
|
||||
* | .text-button | <button>, standard button text |
|
||||
* | .text-button-small | <button>, small/compact button text |
|
||||
*/
|
||||
|
||||
/* body-text-main: Main text style for general body text */
|
||||
.text-body-main {
|
||||
@apply font-inter text-sm font-420;
|
||||
line-height: 21px; /* 150% */
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
/* body-text-paragraph: For paragraphs or larger blocks of text */
|
||||
.text-body-para {
|
||||
@apply font-inter text-sm font-420;
|
||||
line-height: 21px; /* 150% */
|
||||
letter-spacing: -0.21px;
|
||||
}
|
||||
|
||||
/* heading-1: Large heading for pages and panels */
|
||||
.text-heading-1 {
|
||||
@apply font-inter text-lg font-520;
|
||||
line-height: 24px; /* 133.333% */
|
||||
letter-spacing: -0.27px;
|
||||
}
|
||||
|
||||
/* heading-2: Secondary heading for sections */
|
||||
.text-heading-2 {
|
||||
@apply font-inter text-base font-medium;
|
||||
line-height: 24px; /* 133.333% */
|
||||
letter-spacing: -0.27px;
|
||||
}
|
||||
|
||||
/* heading-3: For card headings, breadcrumbs, subsections */
|
||||
.text-heading-3 {
|
||||
@apply font-inter text-sm font-medium;
|
||||
line-height: 21px; /* 150% */
|
||||
letter-spacing: -0.27px;
|
||||
}
|
||||
|
||||
/* label: Standard label text for form fields */
|
||||
.text-label {
|
||||
@apply font-inter text-sm font-medium;
|
||||
line-height: 21px; /* 150% */
|
||||
}
|
||||
|
||||
/* label-small: Smallest font for labels, footnotes, tags */
|
||||
.text-label-small {
|
||||
@apply font-inter text-xs font-440;
|
||||
line-height: 16px; /* 133.333% */
|
||||
letter-spacing: -0.24px;
|
||||
}
|
||||
|
||||
/* button-text: Text for standard size buttons */
|
||||
.text-button {
|
||||
@apply font-inter text-sm font-460;
|
||||
line-height: 21px; /* 150% */
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
/* button-text-small: Text for smaller buttons */
|
||||
.text-button-small {
|
||||
@apply font-inter text-xs font-440;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: -0.24px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Selectize is the one widget whose markup we do not render, so it cannot take
|
||||
// utilities in the templates. These selectors mirror its own specificity.
|
||||
|
||||
.selectize-control .selectize-input,
|
||||
.selectize-control.single .selectize-input.input-active {
|
||||
@apply flex min-h-9 items-center rounded-lg border border-n-slate-6 bg-n-solid-1 px-3 py-1.5 text-body-main text-n-slate-12 shadow-none hover:border-n-slate-8 touch:min-h-11 touch:text-base;
|
||||
background-image: none;
|
||||
|
||||
&.focus {
|
||||
@apply border-n-brand ring-2 ring-n-brand/25;
|
||||
}
|
||||
|
||||
&.dropdown-active::before {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
> input {
|
||||
@apply text-body-main text-n-slate-12 touch:text-base;
|
||||
}
|
||||
}
|
||||
|
||||
.selectize-control.single .selectize-input::after {
|
||||
@apply end-3 border-x-4 border-b-0 border-t-4 border-x-transparent border-t-n-slate-10;
|
||||
}
|
||||
|
||||
.selectize-dropdown {
|
||||
@apply mt-1 overflow-hidden rounded-lg border border-n-weak bg-n-solid-2 text-body-main text-n-slate-12 shadow-lg;
|
||||
|
||||
.option {
|
||||
@apply px-3 py-2;
|
||||
}
|
||||
|
||||
.active {
|
||||
@apply bg-n-alpha-2 text-n-slate-12;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Utilities only the super admin uses, next to Tailwind's own and the shared typography ones.
|
||||
@layer utilities {
|
||||
// The square mosaic from chatwoot.com, drawn with gradients so it needs no image
|
||||
// and follows the theme tokens.
|
||||
.bg-pixel-band {
|
||||
background-image: linear-gradient(to right, rgb(var(--solid-1)) 0.0625rem, transparent 0.0625rem),
|
||||
linear-gradient(to bottom, rgb(var(--solid-1)) 0.0625rem, transparent 0.0625rem),
|
||||
radial-gradient(circle at 88% 10%, rgb(var(--blue-9) / 0.6), transparent 38%),
|
||||
radial-gradient(circle at 62% 110%, rgb(var(--blue-8) / 0.55), transparent 42%),
|
||||
radial-gradient(circle at 30% -20%, rgb(var(--blue-7) / 0.4), transparent 45%),
|
||||
linear-gradient(to right, rgb(var(--blue-2)), rgb(var(--blue-4)));
|
||||
background-size: 0.5rem 0.5rem,
|
||||
0.5rem 0.5rem,
|
||||
100% 100%,
|
||||
100% 100%,
|
||||
100% 100%,
|
||||
100% 100%;
|
||||
}
|
||||
|
||||
// Arrow for native selects, which lose theirs with `appearance-none`.
|
||||
.bg-chevron {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2380838d' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-position: right 0.625rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
@import 'shared/assets/fonts/inter';
|
||||
@import 'shared/assets/fonts/InterDisplay/inter-display';
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import '../typography';
|
||||
@import 'utilities';
|
||||
@import 'selectize';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
body {
|
||||
font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
// FIXME: Use a common color file for all packs
|
||||
// scss-lint:disable PropertySortOrder
|
||||
@@ -24,6 +24,19 @@ body {
|
||||
--slate-11: 96 100 108;
|
||||
--slate-12: 28 32 36;
|
||||
|
||||
--blue-1: 251 253 255;
|
||||
--blue-2: 245 249 255;
|
||||
--blue-3: 233 243 255;
|
||||
--blue-4: 218 236 255;
|
||||
--blue-5: 201 226 255;
|
||||
--blue-6: 181 213 255;
|
||||
--blue-7: 155 195 252;
|
||||
--blue-8: 117 171 247;
|
||||
--blue-9: 39 129 246;
|
||||
--blue-10: 16 115 233;
|
||||
--blue-11: 8 109 224;
|
||||
--blue-12: 11 50 101;
|
||||
|
||||
--iris-1: 253 253 255;
|
||||
--iris-2: 248 248 255;
|
||||
--iris-3: 240 241 254;
|
||||
@@ -63,6 +76,19 @@ body {
|
||||
--amber-11: 171 100 0;
|
||||
--amber-12: 79 52 34;
|
||||
|
||||
--violet-1: 253 252 254;
|
||||
--violet-2: 250 248 255;
|
||||
--violet-3: 244 240 254;
|
||||
--violet-4: 235 228 255;
|
||||
--violet-5: 225 217 255;
|
||||
--violet-6: 212 202 254;
|
||||
--violet-7: 194 178 248;
|
||||
--violet-8: 169 153 236;
|
||||
--violet-9: 110 86 207;
|
||||
--violet-10: 100 84 196;
|
||||
--violet-11: 101 85 183;
|
||||
--violet-12: 47 38 95;
|
||||
|
||||
--teal-1: 250 254 253;
|
||||
--teal-2: 243 251 249;
|
||||
--teal-3: 224 248 243;
|
||||
@@ -112,6 +138,9 @@ body {
|
||||
}
|
||||
|
||||
.dark {
|
||||
// scss-lint:disable PropertySpelling
|
||||
color-scheme: dark;
|
||||
|
||||
--slate-1: 17 17 19;
|
||||
--slate-2: 24 25 27;
|
||||
--slate-3: 33 34 37;
|
||||
@@ -125,6 +154,19 @@ body {
|
||||
--slate-11: 176 180 186;
|
||||
--slate-12: 237 238 240;
|
||||
|
||||
--blue-1: 10 17 28;
|
||||
--blue-2: 15 24 38;
|
||||
--blue-3: 15 39 72;
|
||||
--blue-4: 10 49 99;
|
||||
--blue-5: 18 61 117;
|
||||
--blue-6: 29 84 134;
|
||||
--blue-7: 40 89 156;
|
||||
--blue-8: 48 106 186;
|
||||
--blue-9: 39 129 246;
|
||||
--blue-10: 21 116 231;
|
||||
--blue-11: 126 182 255;
|
||||
--blue-12: 205 227 255;
|
||||
|
||||
--iris-1: 19 19 30;
|
||||
--iris-2: 23 22 37;
|
||||
--iris-3: 32 34 72;
|
||||
@@ -164,6 +206,19 @@ body {
|
||||
--amber-11: 255 202 22;
|
||||
--amber-12: 255 231 179;
|
||||
|
||||
--violet-1: 20 17 31;
|
||||
--violet-2: 27 21 37;
|
||||
--violet-3: 41 31 67;
|
||||
--violet-4: 50 37 85;
|
||||
--violet-5: 60 46 105;
|
||||
--violet-6: 71 56 135;
|
||||
--violet-7: 86 70 151;
|
||||
--violet-8: 110 86 171;
|
||||
--violet-9: 110 86 207;
|
||||
--violet-10: 125 109 217;
|
||||
--violet-11: 169 153 236;
|
||||
--violet-12: 226 221 254;
|
||||
|
||||
--teal-1: 13 21 20;
|
||||
--teal-2: 17 28 27;
|
||||
--teal-3: 13 45 42;
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
const FEATURE_HELP_URLS = {
|
||||
agent_bots: 'https://chwt.app/hc/agent-bots',
|
||||
agents: 'https://chwt.app/hc/agents',
|
||||
audit_logs: 'https://chwt.app/hc/audit-logs',
|
||||
campaigns: 'https://chwt.app/hc/campaigns',
|
||||
canned_responses: 'https://chwt.app/hc/canned',
|
||||
channel_email: 'https://chwt.app/hc/email',
|
||||
channel_facebook: 'https://chwt.app/hc/fb',
|
||||
custom_attributes: 'https://chwt.app/hc/custom-attributes',
|
||||
dashboard_apps: 'https://chwt.app/hc/dashboard-apps',
|
||||
help_center: 'https://chwt.app/hc/help-center',
|
||||
inboxes: 'https://chwt.app/hc/inboxes',
|
||||
integrations: 'https://chwt.app/hc/integrations',
|
||||
labels: 'https://chwt.app/hc/labels',
|
||||
macros: 'https://chwt.app/hc/macros',
|
||||
reports: 'https://chwt.app/hc/reports',
|
||||
sla: 'https://chwt.app/hc/sla',
|
||||
team_management: 'https://chwt.app/hc/teams',
|
||||
webhook: 'https://chwt.app/hc/webhooks',
|
||||
whatsapp_templates:
|
||||
'https://www.chatwoot.com/hc/user-guide/articles/1754940076-whatsapp-templates',
|
||||
billing: 'https://chwt.app/pricing',
|
||||
saml: 'https://chwt.app/hc/saml',
|
||||
captain: 'https://chwt.app/captain-docs',
|
||||
captain_billing: 'https://chwt.app/hc/captain_billing',
|
||||
shopify: 'https://chwt.app/hc/shopify',
|
||||
};
|
||||
import helpUrls from '../../../../config/feature_help_urls.yml';
|
||||
|
||||
export function getHelpUrlForFeature(featureName) {
|
||||
return FEATURE_HELP_URLS[featureName];
|
||||
return helpUrls[featureName];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,207 @@
|
||||
import '../dashboard/assets/scss/super_admin/index.scss';
|
||||
import { initializeCommandBar } from '../superadmin/commandBar';
|
||||
|
||||
// This module is deferred, so the document is already parsed when it runs.
|
||||
|
||||
const swapIcons = button =>
|
||||
button.querySelectorAll('span').forEach(icon => {
|
||||
icon.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
// The <head> script applies the stored theme and follows the system theme; this
|
||||
// applies a choice made with the switch and keeps the switch in sync.
|
||||
const THEME_STORAGE_KEY = 'super-admin-theme';
|
||||
const systemDarkQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const applyTheme = () => {
|
||||
const theme = localStorage.getItem(THEME_STORAGE_KEY) || 'system';
|
||||
const isDark =
|
||||
theme === 'dark' || (theme !== 'light' && systemDarkQuery.matches);
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
document.querySelectorAll('[data-theme-value]').forEach(button => {
|
||||
button.setAttribute(
|
||||
'aria-pressed',
|
||||
String(button.dataset.themeValue === theme)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
applyTheme();
|
||||
|
||||
const closeDropdowns = except => {
|
||||
document.querySelectorAll('details[data-dropdown][open]').forEach(menu => {
|
||||
if (menu !== except) menu.removeAttribute('open');
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const { target } = event;
|
||||
|
||||
const themeButton = target.closest('[data-theme-value]');
|
||||
if (themeButton) {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, themeButton.dataset.themeValue);
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
const dialogOpener = target.closest('[data-dialog-open]');
|
||||
if (dialogOpener) {
|
||||
closeDropdowns();
|
||||
document.getElementById(dialogOpener.dataset.dialogOpen)?.showModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// A click on a dialog's backdrop lands on the dialog element itself.
|
||||
if (target.matches('dialog[data-backdrop-close]')) target.close();
|
||||
|
||||
if (target.closest('[data-support-chat]')) window.$chatwoot?.toggle('open');
|
||||
|
||||
const revealButton = target.closest('[data-reveal]');
|
||||
if (revealButton) {
|
||||
const input = document.getElementById(revealButton.dataset.reveal);
|
||||
input.type = input.type === 'password' ? 'text' : 'password';
|
||||
swapIcons(revealButton);
|
||||
}
|
||||
|
||||
closeDropdowns(target.closest('details[data-dropdown]'));
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') closeDropdowns();
|
||||
});
|
||||
|
||||
// Administrate's table.js opens a row on click, Enter and Space unless the
|
||||
// target is a link, so a button inside a row has to stop the event first.
|
||||
document.addEventListener(
|
||||
'keydown',
|
||||
event => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
if (event.target.matches('.js-table-row button')) event.stopPropagation();
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
document.addEventListener(
|
||||
'click',
|
||||
event => {
|
||||
const button = event.target.closest(
|
||||
'[data-secret-toggle], [data-secret-copy]'
|
||||
);
|
||||
if (!button) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const { secret } = button.closest('[data-secret]').dataset;
|
||||
if (button.matches('[data-secret-copy]')) {
|
||||
if (button.disabled) return;
|
||||
navigator.clipboard.writeText(secret).then(() => {
|
||||
button.disabled = true;
|
||||
swapIcons(button);
|
||||
setTimeout(() => {
|
||||
swapIcons(button);
|
||||
button.disabled = false;
|
||||
}, 1500);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const text = button.parentElement.querySelector('[data-secret-text]');
|
||||
const isRevealed = text.textContent === secret;
|
||||
text.textContent = isRevealed ? '•'.repeat(secret.length || 10) : secret;
|
||||
swapIcons(button);
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
const SPINNER_SVG =
|
||||
'<svg class="animate-spin shrink-0" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>';
|
||||
|
||||
const showPendingLabel = event => {
|
||||
const button = event.submitter;
|
||||
if (!button?.dataset.pendingLabel) return;
|
||||
|
||||
setTimeout(() => {
|
||||
if (event.defaultPrevented) return;
|
||||
const label = document.createElement('span');
|
||||
label.textContent = button.dataset.pendingLabel;
|
||||
button.innerHTML = SPINNER_SVG;
|
||||
button.append(label);
|
||||
button.classList.add('flex', 'items-center', 'gap-2');
|
||||
button.disabled = true;
|
||||
button.setAttribute('aria-busy', 'true');
|
||||
});
|
||||
};
|
||||
|
||||
// The list filter travels inside the search term as `<filter>: <text>`.
|
||||
document.addEventListener('formdata', event => {
|
||||
const filter = event.target.dataset.searchFilter;
|
||||
if (!filter) return;
|
||||
const text = event.formData.get('search') || '';
|
||||
event.formData.set('search', `${filter}: ${text}`.trim());
|
||||
});
|
||||
|
||||
// Posts the form in the background and copies the link it returns. The
|
||||
// clipboard is given a promise so Safari still treats it as part of the click.
|
||||
const copyLinkFromForm = form => {
|
||||
const button = form.querySelector('button');
|
||||
if (button.disabled) return;
|
||||
button.disabled = true;
|
||||
const label = button.textContent;
|
||||
const link = fetch(form.action, {
|
||||
method: 'POST',
|
||||
body: new FormData(form),
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
})
|
||||
.then(data => data.url);
|
||||
const writeText = () => link.then(url => navigator.clipboard.writeText(url));
|
||||
let copy;
|
||||
try {
|
||||
copy = window.ClipboardItem
|
||||
? navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'text/plain': link.then(
|
||||
url => new Blob([url], { type: 'text/plain' })
|
||||
),
|
||||
}),
|
||||
])
|
||||
: writeText();
|
||||
} catch {
|
||||
copy = writeText();
|
||||
}
|
||||
const showResult = text => {
|
||||
button.textContent = text;
|
||||
setTimeout(() => {
|
||||
button.textContent = label;
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
};
|
||||
copy
|
||||
.then(() => showResult(form.dataset.copiedLabel))
|
||||
.catch(() => showResult(form.dataset.failedLabel));
|
||||
};
|
||||
|
||||
document.addEventListener('submit', event => {
|
||||
const form = event.target;
|
||||
if (form.matches('[data-copy-link]')) {
|
||||
event.preventDefault();
|
||||
copyLinkFromForm(form);
|
||||
return;
|
||||
}
|
||||
if (form.target === '_blank') form.closest('dialog')?.close();
|
||||
showPendingLabel(event);
|
||||
});
|
||||
|
||||
document.addEventListener('change', event => {
|
||||
if (!event.target.matches('[data-check-toggle]')) return;
|
||||
event.target
|
||||
.closest('table')
|
||||
.querySelectorAll('tbody [type="checkbox"]')
|
||||
.forEach(checkbox => {
|
||||
checkbox.checked = event.target.checked;
|
||||
});
|
||||
});
|
||||
|
||||
const initializeAccountSuspensionForm = () => {
|
||||
const form = document.querySelector('[data-account-suspension-form]');
|
||||
@@ -35,70 +238,195 @@ const initializeAccountSuspensionForm = () => {
|
||||
updateFields();
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initializeAccountSuspensionForm);
|
||||
// Platform banners: a title and a video only apply to feature announcements.
|
||||
const initializeBannerForm = () => {
|
||||
const type = document.getElementById('platform_banner_banner_type');
|
||||
if (!type) return;
|
||||
|
||||
const SPINNER_SVG =
|
||||
'<svg class="animate-spin shrink-0" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>';
|
||||
|
||||
const initializePendingActions = () => {
|
||||
document.addEventListener('submit', event => {
|
||||
const button = event.submitter;
|
||||
if (!button?.dataset.pendingLabel) return;
|
||||
|
||||
setTimeout(() => {
|
||||
if (event.defaultPrevented) return;
|
||||
const label = document.createElement('span');
|
||||
label.textContent = button.dataset.pendingLabel;
|
||||
button.innerHTML = SPINNER_SVG;
|
||||
button.append(label);
|
||||
button.classList.add('flex', 'items-center', 'gap-2');
|
||||
button.disabled = true;
|
||||
button.setAttribute('aria-busy', 'true');
|
||||
const inputs = ['platform_banner_title', 'platform_banner_video_url'].map(
|
||||
id => document.getElementById(id)
|
||||
);
|
||||
const updateFields = () => {
|
||||
const isAnnouncement = type.value === 'feature_announcement';
|
||||
inputs.forEach(input => {
|
||||
input.disabled = !isAnnouncement;
|
||||
input.closest('[data-field]').classList.toggle('hidden', !isAnnouncement);
|
||||
});
|
||||
};
|
||||
|
||||
// Selectize reports changes through jQuery, which native listeners never hear.
|
||||
window.jQuery(type).on('change', updateFields);
|
||||
updateFields();
|
||||
};
|
||||
|
||||
const showTab = name => {
|
||||
document.querySelectorAll('[data-tab]').forEach(tab => {
|
||||
tab.setAttribute('aria-selected', String(tab.dataset.tab === name));
|
||||
});
|
||||
document.querySelectorAll('[data-tab-panel]').forEach(panel => {
|
||||
panel.hidden = panel.dataset.tabPanel !== name;
|
||||
});
|
||||
// The page reads this cookie to render with the same tab open, so a reload,
|
||||
// a page change or a form submit does not start on the first tab.
|
||||
const value = encodeURIComponent(`${window.location.pathname}#${name}`);
|
||||
document.cookie = `super_admin_tab=${value}; path=/super_admin; SameSite=Lax`;
|
||||
};
|
||||
|
||||
const initializeTabs = () => {
|
||||
document
|
||||
.querySelectorAll('[data-tab]')
|
||||
.forEach(tab =>
|
||||
tab.addEventListener('click', () => showTab(tab.dataset.tab))
|
||||
);
|
||||
|
||||
// The browser cannot focus an invalid control on a hidden tab, so it would drop
|
||||
// the submit without a word. Bring that tab forward before it tries.
|
||||
document.addEventListener(
|
||||
'invalid',
|
||||
event => {
|
||||
const control = event.target;
|
||||
const firstInvalid = control.form?.querySelector(
|
||||
'input:invalid, select:invalid, textarea:invalid'
|
||||
);
|
||||
if (control !== firstInvalid) return;
|
||||
const panel = control.closest('[data-tab-panel]');
|
||||
if (panel?.hidden) showTab(panel.dataset.tabPanel);
|
||||
},
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
const initializeFilters = () => {
|
||||
document.querySelectorAll('[data-filter-scope]').forEach(scope => {
|
||||
const items = [...scope.querySelectorAll('[data-filter-item]')];
|
||||
const checkboxOf = item => item.querySelector('[type="checkbox"]');
|
||||
const checkboxes = items.map(checkboxOf).filter(Boolean);
|
||||
const isOn = item =>
|
||||
checkboxOf(item)?.checked ?? item.dataset.state === 'on';
|
||||
const input = scope.querySelector('[data-filter-input]');
|
||||
const stateButtons = [...scope.querySelectorAll('[data-filter-state]')];
|
||||
const counter = scope.querySelector('[data-check-count]');
|
||||
let state = 'all';
|
||||
|
||||
const updateCount = () => {
|
||||
if (!counter) return;
|
||||
const checked = checkboxes.filter(checkbox => checkbox.checked).length;
|
||||
counter.textContent = counter.dataset.checkCount
|
||||
.replace('%{selected}', checked)
|
||||
.replace('%{total}', checkboxes.length);
|
||||
};
|
||||
|
||||
const applyFilter = () => {
|
||||
const query = input ? input.value.trim().toLowerCase() : '';
|
||||
items.forEach(item => {
|
||||
const matchesText = item.textContent.toLowerCase().includes(query);
|
||||
const matchesState = state === 'all' || isOn(item) === (state === 'on');
|
||||
item.classList.toggle('hidden', !matchesText || !matchesState);
|
||||
});
|
||||
scope.querySelectorAll('[data-filter-group]').forEach(group => {
|
||||
const hasVisibleItem = group.querySelector(
|
||||
'[data-filter-item]:not(.hidden)'
|
||||
);
|
||||
group.classList.toggle('hidden', !hasVisibleItem);
|
||||
});
|
||||
scope.querySelector('[data-filter-empty]')?.classList.toggle(
|
||||
'hidden',
|
||||
items.some(item => !item.classList.contains('hidden'))
|
||||
);
|
||||
};
|
||||
|
||||
const setVisibleCheckboxes = checked => {
|
||||
items
|
||||
.filter(item => !item.classList.contains('hidden'))
|
||||
.map(checkboxOf)
|
||||
.filter(checkbox => checkbox && !checkbox.disabled)
|
||||
.forEach(checkbox => {
|
||||
checkbox.checked = checked;
|
||||
});
|
||||
updateCount();
|
||||
applyFilter();
|
||||
};
|
||||
|
||||
input?.addEventListener('input', applyFilter);
|
||||
input?.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter') event.preventDefault();
|
||||
});
|
||||
stateButtons.forEach(button =>
|
||||
button.addEventListener('click', () => {
|
||||
state = button.dataset.filterState;
|
||||
stateButtons.forEach(other =>
|
||||
other.setAttribute('aria-pressed', String(other === button))
|
||||
);
|
||||
applyFilter();
|
||||
})
|
||||
);
|
||||
scope
|
||||
.querySelector('[data-check-all]')
|
||||
?.addEventListener('click', () => setVisibleCheckboxes(true));
|
||||
scope
|
||||
.querySelector('[data-check-none]')
|
||||
?.addEventListener('click', () => setVisibleCheckboxes(false));
|
||||
// A state filter keeps showing what it filtered on, so re-run it when a box changes.
|
||||
scope.addEventListener('change', () => {
|
||||
updateCount();
|
||||
applyFilter();
|
||||
});
|
||||
updateCount();
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initializePendingActions);
|
||||
const initializeToasts = () => {
|
||||
document.querySelectorAll('[data-toast]').forEach(toast => {
|
||||
let timer;
|
||||
const hide = () => {
|
||||
clearTimeout(timer);
|
||||
toast.classList.add('opacity-0');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
};
|
||||
toast.querySelector('[data-toast-close]').addEventListener('click', hide);
|
||||
if ('toastSticky' in toast.dataset) return;
|
||||
|
||||
const closeDropdowns = except => {
|
||||
document.querySelectorAll('details[data-dropdown][open]').forEach(menu => {
|
||||
if (menu !== except) menu.removeAttribute('open');
|
||||
const schedule = () => {
|
||||
timer = setTimeout(hide, 8000);
|
||||
};
|
||||
['mouseenter', 'focusin'].forEach(type =>
|
||||
toast.addEventListener(type, () => clearTimeout(timer))
|
||||
);
|
||||
['mouseleave', 'focusout'].forEach(type =>
|
||||
toast.addEventListener(type, schedule)
|
||||
);
|
||||
schedule();
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const trigger = event.target.closest('[data-dialog-open]');
|
||||
if (trigger) {
|
||||
closeDropdowns();
|
||||
document.getElementById(trigger.dataset.dialogOpen)?.showModal();
|
||||
return;
|
||||
}
|
||||
closeDropdowns(event.target.closest('details[data-dropdown]'));
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') closeDropdowns();
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// The email suppression result arrives as a query param; drop it so a reload
|
||||
// does not show a stale result.
|
||||
const clearSuppressionParam = () => {
|
||||
const url = new URL(window.location.href);
|
||||
if (!url.searchParams.has('suppression')) return;
|
||||
url.searchParams.delete('suppression');
|
||||
window.history.replaceState(window.history.state, '', url);
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('[data-toast-flashes] .flash').forEach(toast => {
|
||||
let timer;
|
||||
const hide = () => {
|
||||
toast.classList.add('opacity-0');
|
||||
setTimeout(() => toast.classList.add('invisible'), 300);
|
||||
};
|
||||
const schedule = () => {
|
||||
timer = setTimeout(hide, 8000);
|
||||
};
|
||||
toast.addEventListener('mouseenter', () => clearTimeout(timer));
|
||||
toast.addEventListener('mouseleave', schedule);
|
||||
schedule();
|
||||
const initializePageProgress = () => {
|
||||
const bar = document.querySelector('[data-page-progress]');
|
||||
if (!bar || !window.navigation) return;
|
||||
|
||||
window.navigation.addEventListener('navigate', event => {
|
||||
if (event.destination.sameDocument || event.downloadRequest !== null)
|
||||
return;
|
||||
bar.classList.add('animate-page-progress');
|
||||
});
|
||||
});
|
||||
const reset = () => bar.classList.remove('animate-page-progress');
|
||||
window.addEventListener('pageshow', reset);
|
||||
window.navigation.addEventListener('navigateerror', reset);
|
||||
};
|
||||
|
||||
initializeAccountSuspensionForm();
|
||||
initializeBannerForm();
|
||||
initializeTabs();
|
||||
initializeFilters();
|
||||
initializeToasts();
|
||||
clearSuppressionParam();
|
||||
initializePageProgress();
|
||||
initializeCommandBar();
|
||||
|
||||
2
app/javascript/entrypoints/superadmin.scss
Normal file
2
app/javascript/entrypoints/superadmin.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
@config "../../../tailwind.superadmin.config.js";
|
||||
@import '../dashboard/assets/scss/super_admin/index';
|
||||
@@ -1,12 +1,9 @@
|
||||
import '@chatwoot/viz/style.css';
|
||||
import { createApp, h } from 'vue';
|
||||
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||
|
||||
import PlaygroundIndex from '../superadmin_pages/views/playground/Index.vue';
|
||||
import DashboardIndex from '../superadmin_pages/views/dashboard/Index.vue';
|
||||
|
||||
const ComponentMapping = {
|
||||
PlaygroundIndex: PlaygroundIndex,
|
||||
DashboardIndex: DashboardIndex,
|
||||
};
|
||||
|
||||
@@ -20,7 +17,6 @@ const renderComponent = (componentName, props) => {
|
||||
},
|
||||
});
|
||||
|
||||
app.use(VueDOMPurifyHTML);
|
||||
app.mount('#app');
|
||||
};
|
||||
|
||||
|
||||
88
app/javascript/superadmin/commandBar.js
Normal file
88
app/javascript/superadmin/commandBar.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const SELECTED_ID = 'command-bar-selected';
|
||||
|
||||
export const initializeCommandBar = () => {
|
||||
const bar = document.querySelector('[data-command-bar]');
|
||||
const input = bar.querySelector('[data-command-input]');
|
||||
let selected;
|
||||
|
||||
const visibleItems = () => [
|
||||
...bar.querySelectorAll('[data-command-item]:not([hidden])'),
|
||||
];
|
||||
|
||||
// A command that changes data is selected only with the arrow keys, so a stray Enter cannot run it.
|
||||
const isSafe = item => !item.dataset.commandMethod;
|
||||
|
||||
const select = item => {
|
||||
selected?.setAttribute('aria-selected', 'false');
|
||||
selected?.removeAttribute('id');
|
||||
selected = item;
|
||||
selected?.setAttribute('aria-selected', 'true');
|
||||
selected?.setAttribute('id', SELECTED_ID);
|
||||
};
|
||||
|
||||
const filter = () => {
|
||||
const terms = input.value.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
bar.querySelectorAll('[data-command-group]').forEach(group => {
|
||||
const items = [...group.querySelectorAll('[data-command-item]')];
|
||||
items.forEach(item => {
|
||||
const text =
|
||||
`${group.dataset.commandGroup} ${item.textContent}`.toLowerCase();
|
||||
item.hidden = !terms.every(term => text.includes(term));
|
||||
});
|
||||
group.hidden = items.every(item => item.hidden);
|
||||
});
|
||||
select(visibleItems().find(isSafe));
|
||||
};
|
||||
|
||||
input.addEventListener('input', filter);
|
||||
|
||||
bar.addEventListener('keydown', event => {
|
||||
// Enter that confirms an IME composition must not run a command.
|
||||
if (event.isComposing || event.keyCode === 229) return;
|
||||
|
||||
const items = visibleItems();
|
||||
const index = items.indexOf(selected);
|
||||
if (event.key === 'ArrowDown') {
|
||||
select(items[(index + 1) % items.length]);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
select(items.at(Math.max(index, 0) - 1));
|
||||
} else if (event.key === 'Enter' && !event.repeat) {
|
||||
selected?.querySelector('a, button').click();
|
||||
} else if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
// Tab is swallowed too: it would take the focus out of the input.
|
||||
event.preventDefault();
|
||||
selected?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
|
||||
bar.addEventListener('pointermove', event => {
|
||||
const item = event.target.closest('[data-command-item]');
|
||||
if (item && isSafe(item)) select(item);
|
||||
});
|
||||
|
||||
// A click on the backdrop lands on the dialog element itself.
|
||||
bar.addEventListener('click', event => {
|
||||
const opensDialog = event.target.closest('[data-dialog-open]');
|
||||
if (event.target === bar || opensDialog) bar.close();
|
||||
else input.focus();
|
||||
});
|
||||
|
||||
bar.addEventListener('close', () => {
|
||||
input.value = '';
|
||||
filter();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key?.toLowerCase() !== 'k') return;
|
||||
if (!event.metaKey && !event.ctrlKey) return;
|
||||
event.preventDefault();
|
||||
if (bar.open) bar.close();
|
||||
else bar.showModal();
|
||||
});
|
||||
|
||||
// A page restored from the back/forward cache comes back as it was left.
|
||||
window.addEventListener('pageshow', () => bar.close());
|
||||
|
||||
filter();
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full mb-4 flex items-center justify-start">
|
||||
<div
|
||||
v-dompurify-html="message"
|
||||
class="px-4 py-3 bg-white max-w-4xl text-slate-700 leading-6 text-sm rounded-md inline-block border border-slate-100"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,40 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
responseSourcePath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
responseSourceName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="flex items-center px-8 py-4 bg-white border-b border-slate-100"
|
||||
role="banner"
|
||||
>
|
||||
<a :href="responseSourcePath" class="text-woot-500 hover:underline mr-4">
|
||||
{{ 'Back' }}
|
||||
</a>
|
||||
<div
|
||||
class="border border-solid border-slate-100 text-slate-700 mr-4 p-2 rounded-full"
|
||||
>
|
||||
<svg width="24" height="24"><use xlink:href="#icon-mist-fill" /></svg>
|
||||
</div>
|
||||
<div class="flex flex-col h-14 justify-center">
|
||||
<h1 id="page-title" class="text-base font-medium text-slate-900">
|
||||
{{ 'Robin AI playground' }}
|
||||
</h1>
|
||||
<p class="text-sm text-slate-600">
|
||||
{{ 'Chat with the source' }}
|
||||
<span class="font-medium">
|
||||
{{ responseSourceName }}
|
||||
</span>
|
||||
{{ 'and evaluate it’s efficiency.' }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -1,13 +0,0 @@
|
||||
<script setup>
|
||||
import TypingIndicator from './assets/typing.gif';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full mb-4 flex items-center justify-start">
|
||||
<div
|
||||
class="px-2 py-2 bg-white max-w-4xl text-slate-700 leading-6 text-sm rounded-md inline-block border border-slate-100"
|
||||
>
|
||||
<img :src="TypingIndicator" alt="TypingIndicator" class="h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full mb-4 flex items-center justify-end">
|
||||
<div
|
||||
v-dompurify-html="message"
|
||||
class="px-4 py-3 bg-woot-400 text-white text-sm rounded-md inline-block"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
@@ -4,6 +4,20 @@ import format from 'date-fns/format';
|
||||
import parseISO from 'date-fns/parseISO';
|
||||
import BarChart from 'shared/components/charts/BarChart.vue';
|
||||
|
||||
const props = defineProps({
|
||||
componentData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const CHART_COLOR = '#2781F6';
|
||||
const CHART_HEIGHT = 300;
|
||||
const BAR_MAX_WIDTH = 28;
|
||||
const BAR_RADIUS = 6;
|
||||
const Y_TICK_COUNT = 4;
|
||||
const AXIS_DATE_FORMAT = 'd MMM';
|
||||
|
||||
const stats = ref(null);
|
||||
const failed = ref(false);
|
||||
|
||||
@@ -22,63 +36,165 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
const metrics = computed(() => [
|
||||
{ label: 'Accounts', value: stats.value?.accountsCount },
|
||||
{ label: 'Users', value: stats.value?.usersCount },
|
||||
{ label: 'Inboxes', value: stats.value?.inboxesCount },
|
||||
{ label: 'Conversations', value: stats.value?.conversationsCount },
|
||||
{
|
||||
label: 'Accounts',
|
||||
value: stats.value?.accountsCount,
|
||||
icon: 'i-lucide-building-2',
|
||||
tone: 'bg-n-blue-3 text-n-blue-11',
|
||||
href: props.componentData.accountsPath,
|
||||
},
|
||||
{
|
||||
label: 'Users',
|
||||
value: stats.value?.usersCount,
|
||||
icon: 'i-lucide-users',
|
||||
tone: 'bg-n-iris-3 text-n-iris-11',
|
||||
href: props.componentData.usersPath,
|
||||
},
|
||||
{
|
||||
label: 'Inboxes',
|
||||
value: stats.value?.inboxesCount,
|
||||
icon: 'i-lucide-inbox',
|
||||
tone: 'bg-n-teal-3 text-n-teal-11',
|
||||
},
|
||||
{
|
||||
label: 'Conversations',
|
||||
value: stats.value?.conversationsCount,
|
||||
icon: 'i-lucide-messages-square',
|
||||
tone: 'bg-n-amber-3 text-n-amber-11',
|
||||
},
|
||||
]);
|
||||
|
||||
const chartTitle = 'Conversations';
|
||||
const chartCaption = 'Created per day over the last 30 days';
|
||||
const chartAriaLabel = 'Conversations created by day';
|
||||
const loadError =
|
||||
'Could not load the dashboard stats. Reload the page to try again.';
|
||||
|
||||
const chartData = computed(() => {
|
||||
const sourceData = stats.value?.chartData || [];
|
||||
return {
|
||||
categories: sourceData.map(([label]) => format(parseISO(label), 'dd-MMM')),
|
||||
categories: sourceData.map(([label]) =>
|
||||
format(parseISO(label), AXIS_DATE_FORMAT)
|
||||
),
|
||||
series: [
|
||||
{
|
||||
id: 'conversations',
|
||||
label: 'Conversations',
|
||||
color: '#1f93ff',
|
||||
color: CHART_COLOR,
|
||||
data: sourceData.map(([, value]) => value),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// Whole-number steps, so a quiet installation does not get an axis of fractions.
|
||||
const yStepSize = computed(() => {
|
||||
const counts = (stats.value?.chartData || []).map(([, value]) => value);
|
||||
return Math.max(1, Math.ceil(Math.max(0, ...counts) / Y_TICK_COUNT));
|
||||
});
|
||||
|
||||
const chartSummary = computed(() => {
|
||||
const sourceData = stats.value?.chartData || [];
|
||||
if (!sourceData.length) return [];
|
||||
|
||||
const total = sourceData.reduce((sum, [, value]) => sum + value, 0);
|
||||
const [busiestDay] = sourceData.reduce((busiest, day) =>
|
||||
day[1] > busiest[1] ? day : busiest
|
||||
);
|
||||
|
||||
return [
|
||||
{ label: 'Total', value: total.toLocaleString() },
|
||||
{
|
||||
label: 'Daily average',
|
||||
value: Math.round(total / sourceData.length).toLocaleString(),
|
||||
},
|
||||
{
|
||||
label: 'Busiest day',
|
||||
value: format(parseISO(busiestDay), AXIS_DATE_FORMAT),
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<header class="main-content__header" role="banner">
|
||||
<h1 id="page-title" class="main-content__page-title">
|
||||
{{ 'Admin Dashboard' }}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<section class="main-content__body main-content__body--flush">
|
||||
<div class="report--list">
|
||||
<div v-for="item in metrics" :key="item.label" class="report-card">
|
||||
<div class="metric">
|
||||
<div class="flex flex-col gap-6 min-w-0">
|
||||
<ul
|
||||
class="grid grid-cols-2 gap-px overflow-hidden border lg:grid-cols-4 rounded-xl border-n-weak bg-n-weak"
|
||||
>
|
||||
<li v-for="item in metrics" :key="item.label" class="flex bg-n-solid-1">
|
||||
<component
|
||||
:is="item.href ? 'a' : 'div'"
|
||||
:href="item.href"
|
||||
class="flex flex-col flex-1 gap-3 p-4"
|
||||
:class="{ 'hover:bg-n-solid-3': item.href }"
|
||||
>
|
||||
<span
|
||||
class="flex items-center gap-2.5 text-body-main text-n-slate-11"
|
||||
>
|
||||
<span
|
||||
class="grid rounded-lg size-8 place-items-center shrink-0"
|
||||
:class="item.tone"
|
||||
>
|
||||
<span class="size-4" :class="item.icon" />
|
||||
</span>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span
|
||||
class="text-3xl font-semibold tracking-tight font-interDisplay tabular-nums text-n-slate-12"
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
class="inline-block w-20 h-8 rounded bg-woot-100 animate-pulse"
|
||||
class="inline-block w-16 rounded h-8 bg-n-slate-3 animate-pulse"
|
||||
/>
|
||||
<template v-else>{{ item.value || 'N/A' }}</template>
|
||||
</div>
|
||||
<div>{{ item.label }}</div>
|
||||
</span>
|
||||
</component>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<section class="p-4 border rounded-xl border-n-weak bg-n-solid-1">
|
||||
<div class="flex flex-wrap items-start justify-between gap-x-8 gap-y-3">
|
||||
<div>
|
||||
<h2 class="text-heading-3 text-n-slate-12">{{ chartTitle }}</h2>
|
||||
<p class="text-label-small text-n-slate-11">{{ chartCaption }}</p>
|
||||
</div>
|
||||
<dl class="flex flex-wrap gap-x-8 gap-y-2">
|
||||
<div v-for="item in chartSummary" :key="item.label">
|
||||
<dt
|
||||
class="text-xs font-medium uppercase tracking-wider text-n-slate-10"
|
||||
>
|
||||
{{ item.label }}
|
||||
</dt>
|
||||
<dd
|
||||
class="text-base font-semibold font-interDisplay tabular-nums text-n-slate-12"
|
||||
>
|
||||
{{ item.value }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="mt-4 rounded-lg h-[18.75rem] bg-n-slate-3 animate-pulse"
|
||||
/>
|
||||
<p
|
||||
v-else-if="failed"
|
||||
class="py-16 text-center text-body-main text-n-slate-11"
|
||||
>
|
||||
{{ loadError }}
|
||||
</p>
|
||||
<div v-else class="w-full min-w-0 mt-4">
|
||||
<BarChart
|
||||
class="![--cw-viz-bar-axis-font-size:0.6875rem] ![--cw-viz-bar-label-color:rgb(var(--slate-10))]"
|
||||
:data="chartData"
|
||||
:height="CHART_HEIGHT"
|
||||
:max-bar-width="BAR_MAX_WIDTH"
|
||||
:bar-radius="BAR_RADIUS"
|
||||
:y-step-size="yStepSize"
|
||||
timeseries
|
||||
:aria-label="chartAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="p-8 mx-8 h-64 rounded bg-woot-100 animate-pulse"
|
||||
/>
|
||||
<div v-else-if="!failed" class="p-8 w-full min-w-0">
|
||||
<BarChart
|
||||
:data="chartData"
|
||||
:height="500"
|
||||
timeseries
|
||||
:aria-label="chartAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
<script>
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import PlaygroundHeader from '../../components/playground/Header.vue';
|
||||
import UserMessage from '../../components/playground/UserMessage.vue';
|
||||
import BotMessage from '../../components/playground/BotMessage.vue';
|
||||
import TypingIndicator from '../../components/playground/TypingIndicator.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PlaygroundHeader,
|
||||
UserMessage,
|
||||
BotMessage,
|
||||
TypingIndicator,
|
||||
},
|
||||
props: {
|
||||
componentData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
return {
|
||||
formatMessage,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return { messages: [], messageContent: '', isWaiting: false };
|
||||
},
|
||||
computed: {
|
||||
previousMessages() {
|
||||
return this.messages.map(message => ({
|
||||
type: message.type,
|
||||
message: message.content,
|
||||
}));
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.focusInput();
|
||||
},
|
||||
methods: {
|
||||
focusInput() {
|
||||
this.$refs.messageInput.focus();
|
||||
},
|
||||
onMessageSend() {
|
||||
this.addMessageToData('User', this.messageContent);
|
||||
this.sendMessageToServer(this.messageContent);
|
||||
},
|
||||
scrollToLastMessage() {
|
||||
this.$nextTick(() => {
|
||||
const messageId = this.messages[this.messages.length - 1].id;
|
||||
const messageElement = document.getElementById(`message-${messageId}`);
|
||||
messageElement.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
},
|
||||
addMessageToData(type, content) {
|
||||
this.messages.push({ id: this.messages.length, type, content });
|
||||
this.scrollToLastMessage();
|
||||
},
|
||||
async sendMessageToServer(messageContent) {
|
||||
this.messageContent = '';
|
||||
this.isWaiting = true;
|
||||
const csrfToken = document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
.getAttribute('content');
|
||||
|
||||
try {
|
||||
const response = await fetch(window.location.href, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: messageContent,
|
||||
previous_messages: this.previousMessages,
|
||||
}),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const { message } = await response.json();
|
||||
this.addMessageToData('Bot', message);
|
||||
} catch (error) {
|
||||
this.addMessageToData(
|
||||
'bot',
|
||||
'Error: Could not retrieve response. Please check the console for more details.'
|
||||
);
|
||||
} finally {
|
||||
this.isWaiting = false;
|
||||
this.focusInput();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full bg-slate-25">
|
||||
<PlaygroundHeader
|
||||
:response-source-name="componentData.responseSourceName"
|
||||
:response-source-path="componentData.responseSourcePath"
|
||||
/>
|
||||
<div class="flex-1 px-8 py-4 overflow-auto">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:id="`message-${message.id}`"
|
||||
:key="message.id"
|
||||
>
|
||||
<UserMessage
|
||||
v-if="message.type === 'User'"
|
||||
:message="formatMessage(message.content)"
|
||||
/>
|
||||
<BotMessage v-else :message="formatMessage(message.content)" />
|
||||
</div>
|
||||
<TypingIndicator v-if="isWaiting" />
|
||||
</div>
|
||||
<div class="w-full px-8 py-6">
|
||||
<textarea
|
||||
ref="messageInput"
|
||||
v-model="messageContent"
|
||||
:rows="4"
|
||||
class="resize-none block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border !outline-2 border-slate-100 focus:ring-woot-500 focus:border-woot-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-woot-500 dark:focus:border-woot-500"
|
||||
placeholder="Type a message... [CMD/CTRL + Enter to send]"
|
||||
autofocus
|
||||
autocomplete="off"
|
||||
@keydown.meta.enter="onMessageSend"
|
||||
@keydown.ctrl.enter="onMessageSend"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,65 +1,54 @@
|
||||
<div class="field-unit__label">
|
||||
<%= f.label field.attribute %>
|
||||
</div>
|
||||
<%
|
||||
if f.object.persisted?
|
||||
submitted_account = params[:account]
|
||||
latest_suspension = field.resource.suspended? ? field.latest_suspension : {}
|
||||
category = submitted_account&.key?(:suspension_category) ? submitted_account[:suspension_category] : latest_suspension['category']
|
||||
reason = submitted_account&.key?(:suspension_reason) ? submitted_account[:suspension_reason] : latest_suspension['reason']
|
||||
suspension_selected = field.data == 'suspended'
|
||||
end
|
||||
detail_label = 'text-label-small text-n-slate-11'
|
||||
%>
|
||||
|
||||
<div class="field-unit__field" data-account-suspension-form data-original-status="<%= field.original_status %>" data-has-suspension-history="<%= field.resource.suspension_history.present? %>">
|
||||
<%= f.select(
|
||||
field.attribute,
|
||||
options_for_select(field.selectable_options, field.data),
|
||||
{ include_blank: field.include_blank_option },
|
||||
{
|
||||
class: 'rounded border border-n-weak bg-white p-2',
|
||||
data: { account_status_select: true }
|
||||
}
|
||||
) %>
|
||||
<%= render "field", f: f, field: field do %>
|
||||
<div data-account-suspension-form data-original-status="<%= field.original_status %>" data-has-suspension-history="<%= field.resource.suspension_history.present? %>">
|
||||
<%= f.select(
|
||||
field.attribute,
|
||||
options_for_select(field.selectable_options, field.data),
|
||||
{ include_blank: field.include_blank_option },
|
||||
{ class: ui(:select), data: { account_status_select: true } }
|
||||
) %>
|
||||
|
||||
<% if f.object.persisted? %>
|
||||
<%
|
||||
submitted_account = params[:account]
|
||||
latest_suspension = field.resource.suspended? ? field.latest_suspension : {}
|
||||
category = if submitted_account&.key?(:suspension_category)
|
||||
submitted_account[:suspension_category]
|
||||
else
|
||||
latest_suspension['category']
|
||||
end
|
||||
reason = if submitted_account&.key?(:suspension_reason)
|
||||
submitted_account[:suspension_reason]
|
||||
else
|
||||
latest_suspension['reason']
|
||||
end
|
||||
suspension_selected = field.data == 'suspended'
|
||||
%>
|
||||
|
||||
<div class="mt-6 space-y-4 <%= 'hidden' unless suspension_selected %>" data-account-suspension-fields>
|
||||
<div>
|
||||
<%= label_tag :account_suspension_category, t('super_admin.account_suspension.form.category'), class: 'font-normal' %>
|
||||
<%= select_tag(
|
||||
'account[suspension_category]',
|
||||
options_for_select(
|
||||
[[t('super_admin.account_suspension.form.category_prompt'), '']] + field.suspension_category_options,
|
||||
category
|
||||
),
|
||||
id: :account_suspension_category,
|
||||
disabled: !suspension_selected,
|
||||
class: 'mt-1 rounded border border-n-weak bg-white p-2',
|
||||
data: { suspension_category: true }
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag :account_suspension_reason, t('super_admin.account_suspension.form.reason'), class: 'font-normal' %>
|
||||
<%= text_area_tag(
|
||||
'account[suspension_reason]',
|
||||
reason,
|
||||
id: :account_suspension_reason,
|
||||
rows: 4,
|
||||
maxlength: 256,
|
||||
disabled: !suspension_selected,
|
||||
class: 'mt-1 rounded border border-n-weak bg-white p-2',
|
||||
data: { suspension_reason: true }
|
||||
) %>
|
||||
<p class="mt-1 text-xs text-n-slate-11"><%= t('super_admin.account_suspension.form.reason_hint') %></p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if f.object.persisted? %>
|
||||
<%= tag.div class: class_names("mt-4 space-y-4", hidden: !suspension_selected), data: { account_suspension_fields: true } do %>
|
||||
<div>
|
||||
<%= label_tag :account_suspension_category, t('super_admin.account_suspension.form.category'), class: detail_label %>
|
||||
<%= select_tag(
|
||||
'account[suspension_category]',
|
||||
options_for_select(
|
||||
[[t('super_admin.account_suspension.form.category_prompt'), '']] + field.suspension_category_options,
|
||||
category
|
||||
),
|
||||
id: :account_suspension_category,
|
||||
disabled: !suspension_selected,
|
||||
class: ui(:select, "mt-1"),
|
||||
data: { suspension_category: true }
|
||||
) %>
|
||||
</div>
|
||||
<div>
|
||||
<%= label_tag :account_suspension_reason, t('super_admin.account_suspension.form.reason'), class: detail_label %>
|
||||
<%= text_area_tag(
|
||||
'account[suspension_reason]',
|
||||
reason,
|
||||
id: :account_suspension_reason,
|
||||
rows: 4,
|
||||
maxlength: 256,
|
||||
disabled: !suspension_selected,
|
||||
class: ui(:input, "mt-1"),
|
||||
data: { suspension_reason: true }
|
||||
) %>
|
||||
<p class="mt-1 <%= detail_label %>"><%= t('super_admin.account_suspension.form.reason_hint') %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
28
app/views/fields/active_storage/_form.html.erb
Normal file
28
app/views/fields/active_storage/_form.html.erb
Normal file
@@ -0,0 +1,28 @@
|
||||
<%= render "field", f: f, field: field do %>
|
||||
<% if field.attached? %>
|
||||
<ul class="mb-3 space-y-2">
|
||||
<% field.attachments.each do |attachment| %>
|
||||
<li class="flex items-center gap-3 rounded-lg border border-n-weak p-2">
|
||||
<%= image_tag field.variant(attachment, resize_to_limit: [112, 112]), alt: "", class: "size-14 shrink-0 rounded-md object-cover" %>
|
||||
<div class="min-w-0 flex-1">
|
||||
<%= link_to attachment.filename, field.blob_url(attachment), class: "block truncate text-label text-n-slate-12 hover:underline" %>
|
||||
<p class="text-label-small text-n-slate-11"><%= number_to_human_size(attachment.byte_size) %></p>
|
||||
</div>
|
||||
<% if field.destroy_url %>
|
||||
<%= render "button",
|
||||
icon: "i-lucide-trash-2 size-4",
|
||||
url: field.destroy_url.call(namespace, field.data.record, attachment),
|
||||
variant: :faded_danger,
|
||||
size: :icon,
|
||||
title: t("administrate.fields.active_storage.remove", default: "Remove"),
|
||||
aria: { label: t("administrate.fields.active_storage.remove", default: "Remove") },
|
||||
method: :delete,
|
||||
data: { confirm: t("administrate.actions.confirm") } %>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
<%= f.file_field field.attribute, multiple: field.many?, direct_upload: field.direct?,
|
||||
class: "block w-full text-body-main text-n-slate-11 file:me-3 file:h-9 file:cursor-pointer file:rounded-lg file:border file:border-solid file:border-n-slate-6 file:bg-n-solid-1 file:px-3 file:text-button file:text-n-slate-12" %>
|
||||
<% end %>
|
||||
@@ -1 +1 @@
|
||||
<%= image_tag field.avatar_url if field.avatar_url.present? %>
|
||||
<%= image_tag field.avatar_url, alt: "", decoding: "async", class: "me-2 inline-block size-7 max-w-none rounded-lg border border-n-weak object-cover align-middle" %>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<%= image_tag field.avatar_url if field.avatar_url.present? %>
|
||||
<%= image_tag field.avatar_url, alt: "", class: "size-8 rounded-full object-cover" %>
|
||||
|
||||
@@ -1,26 +1,10 @@
|
||||
<%#
|
||||
# BelongsTo Form Partial
|
||||
|
||||
This partial renders an input element for belongs_to relationships.
|
||||
By default, the input is a collection select box
|
||||
that displays all possible records to associate with.
|
||||
|
||||
## Local variables:
|
||||
|
||||
- `f`:
|
||||
A Rails form generator, used to help create the appropriate input fields.
|
||||
- `field`:
|
||||
An instance of [Administrate::Field::BelongsTo][1].
|
||||
Contains helper methods for displaying a collection select box.
|
||||
|
||||
[1]: http://www.rubydoc.info/gems/administrate/Administrate/Field/BelongsTo
|
||||
%>
|
||||
|
||||
<div class="field-unit__label">
|
||||
<%= f.label field.permitted_attribute %>
|
||||
</div>
|
||||
<div class="field-unit__field">
|
||||
<%= f.select(field.permitted_attribute,
|
||||
options_for_select(field.associated_resource_options, field.selected_option),
|
||||
include_blank: field.include_blank_option) %>
|
||||
</div>
|
||||
<%= render "field", f: f, field: field, attribute: field.permitted_attribute do %>
|
||||
<%# Administrate turns selects inside this class into searchable selectize inputs. %>
|
||||
<div class="field-unit--belongs-to">
|
||||
<%= f.select(
|
||||
field.permitted_attribute,
|
||||
options_for_select(field.associated_resource_options, field.selected_option),
|
||||
include_blank: field.include_blank_option
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
8
app/views/fields/belongs_to_search/_form.html.erb
Normal file
8
app/views/fields/belongs_to_search/_form.html.erb
Normal file
@@ -0,0 +1,8 @@
|
||||
<%= render "field", f: f, field: field, attribute: field.permitted_attribute do %>
|
||||
<%# The belongs_to_search gem turns selects inside this class into selectize inputs that search `data-url`. %>
|
||||
<div class="field-unit--belongs-to-search">
|
||||
<%= f.select(field.permitted_attribute, nil, {}, "data-url": polymorphic_url([namespace, field.associated_class], format: :json)) do %>
|
||||
<%= options_for_select(field.associated_resource_options, field.selected_option) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
3
app/views/fields/boolean/_form.html.erb
Normal file
3
app/views/fields/boolean/_form.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<%= render "field", f: f, field: field do %>
|
||||
<%= f.check_box field.attribute, class: ui(:checkbox, "sm:mt-2") %>
|
||||
<% end %>
|
||||
@@ -1,8 +1,5 @@
|
||||
<div class="field-unit__label">
|
||||
<%= f.label field.attribute %>
|
||||
</div>
|
||||
<div class="field-unit__field">
|
||||
<%= render "field", f: f, field: field do %>
|
||||
<% value = field.data %>
|
||||
<% value = Time.current if value.blank? && action_name == 'new' %>
|
||||
<%= f.datetime_local_field field.attribute, step: 1, value: value %>
|
||||
</div>
|
||||
<%= f.datetime_local_field field.attribute, step: 1, value: value, class: ui(:input) %>
|
||||
<% end %>
|
||||
|
||||
18
app/views/fields/has_one/_show.html.erb
Normal file
18
app/views/fields/has_one/_show.html.erb
Normal file
@@ -0,0 +1,18 @@
|
||||
<% if field.linkable? %>
|
||||
<p class="text-label">
|
||||
<%= link_to_if(
|
||||
accessible_action?(field.data, :show),
|
||||
field.display_associated_resource,
|
||||
[namespace, field.data],
|
||||
) %>
|
||||
</p>
|
||||
<dl class="[&>div:last-child]:border-b-0">
|
||||
<% field.nested_show.attributes.each_value do |attributes| %>
|
||||
<% attributes.each do |attribute| %>
|
||||
<%= render "attribute", label: t("helpers.label.#{field.associated_class_name.underscore}.#{attribute.name}", default: attribute.name.titleize) do %>
|
||||
<%= render_field attribute, page: page %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</dl>
|
||||
<% end %>
|
||||
3
app/views/fields/password/_form.html.erb
Normal file
3
app/views/fields/password/_form.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<%= render "field", f: f, field: field do %>
|
||||
<%= f.password_field field.attribute, value: field.data, class: ui(:input) %>
|
||||
<% end %>
|
||||
@@ -1,29 +0,0 @@
|
||||
<%#
|
||||
# Polymorphic Form Partial
|
||||
|
||||
This partial renders an input element for polymorphic relationships.
|
||||
|
||||
## Local variables:
|
||||
|
||||
- `f`:
|
||||
A Rails form generator, used to help create the appropriate input fields.
|
||||
- `field`:
|
||||
An instance of [Administrate::Field::Polymorphic][1].
|
||||
A wrapper around the polymorphic belongs_to relationship
|
||||
pulled from the database.
|
||||
|
||||
[1]: http://www.rubydoc.info/gems/administrate/Administrate/Field/Polymorphic
|
||||
%>
|
||||
|
||||
<%= f.fields_for field.attribute do |pf| %>
|
||||
<div class="field-unit__label">
|
||||
<%= pf.label :value, field.name.humanize %>
|
||||
</div>
|
||||
|
||||
<div class="field-unit__field">
|
||||
<%= pf.hidden_field(:type, value: field.class.name) %>
|
||||
<%= pf.select(:value) do %>
|
||||
<%= grouped_options_for_select(field.associated_resource_grouped_options, field.selected_global_id, prompt: true) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,17 +1 @@
|
||||
<%#
|
||||
# SecretField Index Partial
|
||||
%>
|
||||
<%= javascript_include_tag "secretField" %>
|
||||
<div data-secret-text="<%= field.data %>" class="cell-data__secret-field">
|
||||
<span data-secret-masked="true"></span>
|
||||
<button onclick="toggleSecretField(event)" data-secret-toggler>
|
||||
<svg width="20" height="20">
|
||||
<use xlink:href="#eye-show" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="copySecretField(event)" data-secret-copier>
|
||||
<svg width="20" height="20">
|
||||
<use xlink:href="#icon-copy" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<%= render "secret", value: field.data %>
|
||||
|
||||
@@ -1,18 +1 @@
|
||||
<%#
|
||||
# SecretField Show Partial
|
||||
%>
|
||||
<%= javascript_include_tag "secretField" %>
|
||||
|
||||
<div data-secret-text="<%= field.data %>" class="cell-data__secret-field">
|
||||
<span data-secret-masked="true"></span>
|
||||
<button onclick="toggleSecretField(event)" data-secret-toggler>
|
||||
<svg width="20" height="20">
|
||||
<use xlink:href="#eye-show" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="copySecretField(event)" data-secret-copier>
|
||||
<svg width="20" height="20">
|
||||
<use xlink:href="#icon-copy" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<%= render "secret", value: field.data %>
|
||||
|
||||
12
app/views/fields/select/_form.html.erb
Normal file
12
app/views/fields/select/_form.html.erb
Normal file
@@ -0,0 +1,12 @@
|
||||
<%# Stored values read as labels ("feature_announcement" becomes "Feature Announcement"); locale codes stay as they are. %>
|
||||
<% options = field.attribute == :locale ? field.selectable_options : field.selectable_options.map { |value| [value.to_s.titleize, value] } %>
|
||||
<%= render "field", f: f, field: field do %>
|
||||
<%# Administrate turns selects inside this class into searchable selectize inputs. %>
|
||||
<div class="field-unit--select">
|
||||
<%= f.select(
|
||||
field.attribute,
|
||||
options_for_select(options, field.data),
|
||||
include_blank: field.include_blank_option
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
3
app/views/fields/select/_index.html.erb
Normal file
3
app/views/fields/select/_index.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<% if field.data.present? %>
|
||||
<%= render "badge", label: field.attribute == :locale ? field.data : field.data.titleize %>
|
||||
<% end %>
|
||||
3
app/views/fields/select/_show.html.erb
Normal file
3
app/views/fields/select/_show.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<% if field.data.present? %>
|
||||
<%= render "badge", label: field.attribute == :locale ? field.data : field.data.titleize %>
|
||||
<% end %>
|
||||
@@ -1,19 +1,16 @@
|
||||
<div class="field-unit field-unit--string">
|
||||
<%= f.label field.attribute, class: "field-unit__label" %>
|
||||
<div class="field-unit__field">
|
||||
<% if field.array? %>
|
||||
<% field.data.each do |sub_field| %>
|
||||
<%= f.fields_for "#{field.attribute}[]", field.resource do |values_form| %>
|
||||
<div class="field-unit">
|
||||
<% sub_field.each do |sf_key, sf_value| %>
|
||||
<%= values_form.label sf_key %>
|
||||
<%= values_form.text_field sf_key, value: sf_value, disabled: true %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render "field", f: f, field: field do %>
|
||||
<% if field.array? %>
|
||||
<% field.data.each do |sub_field| %>
|
||||
<%= f.fields_for "#{field.attribute}[]", field.resource do |values_form| %>
|
||||
<div class="mb-3 space-y-1.5">
|
||||
<% sub_field.each do |sf_key, sf_value| %>
|
||||
<%= values_form.label sf_key, class: "text-label-small text-n-slate-11" %>
|
||||
<%= values_form.text_field sf_key, value: sf_value, disabled: true, class: ui(:input) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= f.text_field field.attribute %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= f.text_field field.attribute, class: ui(:input) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<% if field.array? %>
|
||||
<% field.data.each do |sub_field| %>
|
||||
<div>
|
||||
<%= sub_field.to_s %>
|
||||
</div>
|
||||
<div><%= sub_field.to_s %></div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= field.to_s %>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<% if field.array? %>
|
||||
<% field.data.each do |sub_field| %>
|
||||
<div>
|
||||
<%= sub_field.to_s %>
|
||||
</div>
|
||||
<div><%= sub_field.to_s %></div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= field.to_s %>
|
||||
|
||||
3
app/views/fields/string/_form.html.erb
Normal file
3
app/views/fields/string/_form.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<%= render "field", f: f, field: field do %>
|
||||
<%= f.text_field field.attribute, class: ui(:input) %>
|
||||
<% end %>
|
||||
1
app/views/fields/string/_show.html.erb
Normal file
1
app/views/fields/string/_show.html.erb
Normal file
@@ -0,0 +1 @@
|
||||
<div class="whitespace-pre-wrap"><%= field.data %></div>
|
||||
@@ -1,26 +1,26 @@
|
||||
<% if field.events.present? %>
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-n-weak text-left text-sm">
|
||||
<div class="overflow-x-auto rounded-b-xl">
|
||||
<%= render "table" do %>
|
||||
<thead>
|
||||
<tr class="text-n-slate-11">
|
||||
<th class="px-3 py-2 font-medium"><%= t('super_admin.account_suspension.history.suspended_at') %></th>
|
||||
<th class="px-3 py-2 font-medium"><%= t('super_admin.account_suspension.history.category') %></th>
|
||||
<th class="px-3 py-2 font-medium"><%= t('super_admin.account_suspension.history.reason') %></th>
|
||||
<th class="px-3 py-2 font-medium"><%= t('super_admin.account_suspension.history.suspended_by') %></th>
|
||||
<tr>
|
||||
<th><%= t('super_admin.account_suspension.history.suspended_at') %></th>
|
||||
<th><%= t('super_admin.account_suspension.history.category') %></th>
|
||||
<th><%= t('super_admin.account_suspension.history.reason') %></th>
|
||||
<th><%= t('super_admin.account_suspension.history.suspended_by') %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-n-weak">
|
||||
<tbody>
|
||||
<% field.events.each do |event| %>
|
||||
<tr class="align-top text-n-slate-12">
|
||||
<td class="whitespace-nowrap px-3 py-3"><%= field.suspended_at(event) %></td>
|
||||
<td class="whitespace-nowrap px-3 py-3"><%= field.category_label(event) %></td>
|
||||
<td class="whitespace-pre-wrap break-words px-3 py-3"><%= event.fetch('reason') %></td>
|
||||
<td class="px-3 py-3"><%= field.suspended_by(event) %></td>
|
||||
<tr class="align-top">
|
||||
<td class="whitespace-nowrap"><%= field.suspended_at(event) %></td>
|
||||
<td class="whitespace-nowrap"><%= field.category_label(event) %></td>
|
||||
<td class="whitespace-pre-wrap break-words"><%= event.fetch('reason') %></td>
|
||||
<td><%= field.suspended_by(event) %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-sm text-n-slate-11"><%= t('super_admin.account_suspension.history.empty') %></p>
|
||||
<p class="p-4 text-body-main text-n-slate-11"><%= t('super_admin.account_suspension.history.empty') %></p>
|
||||
<% end %>
|
||||
|
||||
3
app/views/fields/text/_form.html.erb
Normal file
3
app/views/fields/text/_form.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<%= render "field", f: f, field: field do %>
|
||||
<%= f.text_area field.attribute, rows: 4, class: ui(:input) %>
|
||||
<% end %>
|
||||
1
app/views/fields/text/_show.html.erb
Normal file
1
app/views/fields/text/_show.html.erb
Normal file
@@ -0,0 +1 @@
|
||||
<div class="whitespace-pre-wrap"><%= field.data %></div>
|
||||
@@ -1,80 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="robots" content="noindex">
|
||||
<title>SuperAdmin | Chatwoot</title>
|
||||
<%= vite_client_tag %>
|
||||
<%= vite_javascript_tag 'superadmin' %>
|
||||
</head>
|
||||
<body>
|
||||
<div class="h-full w-full antialiased">
|
||||
<main class="flex flex-col bg-woot-25 min-h-screen w-full py-20 sm:px-6 lg:px-8 dark:bg-slate-900">
|
||||
<section class="max-w-5xl mx-auto">
|
||||
<img src="/brand-assets/logo.svg" alt="Chatwoot" class="mx-auto h-8 w-auto block dark:hidden">
|
||||
<img src="/brand-assets/logo_dark.svg" alt="Chatwoot" class="mx-auto h-8 w-auto hidden dark:block">
|
||||
<h2 class="mt-6 text-center text-3xl font-medium text-slate-900 dark:text-woot-50">
|
||||
Howdy, Welcome to Chatwoot 👋
|
||||
</h2>
|
||||
</section>
|
||||
<section class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-slate-800 p-11 sm:shadow-lg sm:rounded-lg mb-8 mt-15">
|
||||
<div>
|
||||
<%= form_tag('/installation/onboarding') do %>
|
||||
<div class="space-y-5">
|
||||
<% if flash[:error].present? %>
|
||||
<div class="rounded-md bg-red-500 p-4 text-white text-sm mb-5"><%= flash[:error] %></div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<label for="name" class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white">
|
||||
Name
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<%= text_field :user, :name, placeholder: "Enter your full name. eg: Bruce Wayne", required: true, class: "block w-full rounded-md border-0 px-3 py-3 appearance-none shadow-sm ring-1 ring-inset text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-woot-500 sm:text-sm sm:leading-6 outline-none dark:bg-slate-700 dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="name" class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white">
|
||||
Company Name
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<%= text_field :user, :company, placeholder: "Enter your company name. eg: Wayne Enterprises", required: true, class: "block w-full rounded-md border-0 px-3 py-3 appearance-none shadow-sm ring-1 ring-inset text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-woot-500 sm:text-sm sm:leading-6 outline-none dark:bg-slate-700 dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="name" class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white">
|
||||
Work Email
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<%= email_field :user, :email, placeholder: "Enter your work email address. eg: bruce@wayne.enterprises", required: true, class: "block w-full rounded-md border-0 px-3 py-3 appearance-none shadow-sm ring-1 ring-inset text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-woot-500 sm:text-sm sm:leading-6 outline-none dark:bg-slate-700 dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="name" class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white">
|
||||
Password
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<%= password_field :user, :password, placeholder: "Enter a password with 6 characters or more.", required: true, class: "block w-full rounded-md border-0 px-3 py-3 appearance-none shadow-sm ring-1 ring-inset text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-woot-500 sm:text-sm sm:leading-6 outline-none dark:bg-slate-700 dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-slate-900">
|
||||
<%= check_box_tag "subscribe_to_updates", 'true', true %>
|
||||
<label class="ml-2" for="subscribe_to_updates">
|
||||
Subscribe to release notes, newsletters & product feedback surveys.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="flex items-center w-full justify-center rounded-md bg-woot-500 py-3 px-3 text-base font-medium text-white shadow-sm hover:bg-woot-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-woot-500 cursor-pointer">
|
||||
Finish Setup
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<%= render "super_admin/application/standalone", heading: t("installation.onboarding.heading") do %>
|
||||
<%= form_tag('/installation/onboarding', class: 'space-y-4') do %>
|
||||
<div>
|
||||
<label for="user_name" class="text-label"><%= t("installation.onboarding.name") %></label>
|
||||
<%= text_field :user, :name, placeholder: t("installation.onboarding.name_placeholder"), required: true, class: ui(:input, "mt-1 min-h-10") %>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<div>
|
||||
<label for="user_company" class="text-label"><%= t("installation.onboarding.company") %></label>
|
||||
<%= text_field :user, :company, placeholder: t("installation.onboarding.company_placeholder"), required: true, class: ui(:input, "mt-1 min-h-10") %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="user_email" class="text-label"><%= t("installation.onboarding.email") %></label>
|
||||
<%= email_field :user, :email, placeholder: t("installation.onboarding.email_placeholder"), required: true, class: ui(:input, "mt-1 min-h-10") %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="user_password" class="text-label"><%= t("installation.onboarding.password") %></label>
|
||||
<%= password_field :user, :password, placeholder: t("installation.onboarding.password_placeholder"), required: true, class: ui(:input, "mt-1 min-h-10") %>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-2 text-body-main" for="subscribe_to_updates">
|
||||
<%= check_box_tag "subscribe_to_updates", 'true', true, class: ui(:checkbox, "mt-0.5") %>
|
||||
<%= t("installation.onboarding.subscribe") %>
|
||||
</label>
|
||||
|
||||
<%= render "super_admin/application/button", label: t("installation.onboarding.submit"), type: "submit", extra: "h-10 w-full" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,40 +1,30 @@
|
||||
<%#
|
||||
# Application Layout
|
||||
|
||||
This view template is used as the layout
|
||||
for every page that Administrate generates.
|
||||
|
||||
By default, it renders:
|
||||
- Navigation
|
||||
- Content for a search bar
|
||||
(if provided by a `content_for` block in a nested page)
|
||||
- Flashes
|
||||
- Links to stylesheets and JavaScripts
|
||||
%>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="<%= I18n.locale %>" class="w-full h-full">
|
||||
<html lang="<%= I18n.locale %>" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="viewport" content="initial-scale=1">
|
||||
<title>
|
||||
<%= content_for(:title) %> - <%= application_title %>
|
||||
</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= content_for(:title) %> - <%= application_title %></title>
|
||||
<%= render "theme" %>
|
||||
<%= render "font_preload", file: "Inter/InterVariable.woff2" %>
|
||||
<%= render "stylesheet" %>
|
||||
<%= vite_client_tag %>
|
||||
<%= vite_stylesheet_tag 'superadmin.scss' %>
|
||||
<%= vite_javascript_tag 'superadmin' %>
|
||||
<%= yield :head %>
|
||||
<%= csrf_meta_tags %>
|
||||
<%= render "prefetch" %>
|
||||
</head>
|
||||
<body class="antialiased w-full h-full">
|
||||
<%= render "icons" %>
|
||||
<div class="flex w-full h-full">
|
||||
<%= render "navigation" -%>
|
||||
<main class="w-full overflow-auto" role="main">
|
||||
<%= render "flashes" -%>
|
||||
<body class="min-h-full bg-n-background font-inter text-n-slate-12 antialiased">
|
||||
<div class="pointer-events-none fixed inset-x-0 top-0 z-50 h-0.5 origin-left scale-x-0 bg-n-brand" data-page-progress></div>
|
||||
<div class="lg:flex">
|
||||
<%= render "navigation" %>
|
||||
<main class="mx-auto w-full min-w-0 max-w-[120rem] flex-1 px-[clamp(1rem,3vw,3rem)] pb-12">
|
||||
<%= render "flashes" %>
|
||||
<%= yield %>
|
||||
</main>
|
||||
</div>
|
||||
<%= render "command_bar" %>
|
||||
<%= render "javascript" %>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<section class="main-content__body">
|
||||
<hr/>
|
||||
<%= form_for([:reset_cache, namespace, page.resource], method: :post, html: { class: "form" }) do |f| %>
|
||||
<div class="form-actions">
|
||||
<p class="pb-3">This will clear the IndexedDB cache keys from redis. <br>The next load will fetch the data from backend.</p>
|
||||
<%= f.submit 'Reset Frontend Cache' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
<%# locals: (page:) -%>
|
||||
<%= form_for([:reset_cache, namespace, page.resource], method: :post, html: { id: nil, class: "flex flex-wrap items-center justify-between gap-x-6 gap-y-3 p-4" }) do |f| %>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-heading-3"><%= t("super_admin.accounts.reset_cache") %></h2>
|
||||
<p class="text-body-main text-n-slate-11"><%= t("super_admin.accounts.reset_cache_description") %></p>
|
||||
</div>
|
||||
<%= render "button", label: t("super_admin.accounts.reset_cache_button"), variant: :secondary, submit: f %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<% if ENV.fetch('ENABLE_ACCOUNT_SEEDING', !Rails.env.production?) %>
|
||||
<section class="main-content__body">
|
||||
<hr/>
|
||||
<%= form_for([:seed, namespace, page.resource], method: :post, html: { class: "form" }) do |f| %>
|
||||
|
||||
<div class="form-actions">
|
||||
<div class="pb-3">
|
||||
<p>Click the button to generate seed data into this account for demos.</p>
|
||||
<p class="text-color-red">Note: This will clear all the existing data in this account.</p>
|
||||
<%# locals: (page:) -%>
|
||||
<% if Seeders::AccountSeeder.allowed? %>
|
||||
<%= form_for([:seed, namespace, page.resource], method: :post, html: { id: nil, class: "flex flex-wrap items-center justify-between gap-x-6 gap-y-3 p-4" }) do |f| %>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-heading-3"><%= t("super_admin.accounts.seed_data") %></h2>
|
||||
<p class="text-body-main text-n-slate-11"><%= t("super_admin.accounts.seed_data_description") %></p>
|
||||
<p class="text-body-main text-n-ruby-11"><%= t("super_admin.accounts.seed_data_warning") %></p>
|
||||
</div>
|
||||
<%= f.submit 'Generate Seed Data' %>
|
||||
</div>
|
||||
<%= render "button", label: t("super_admin.accounts.seed_data_button"), variant: :danger, submit: f %>
|
||||
<% end %>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<% content_for(:title) { t("administrate.actions.edit_resource", name: page.page_title) } %>
|
||||
|
||||
<header class="main-content__header">
|
||||
<h1 class="main-content__page-title">
|
||||
<%= content_for(:title) %>
|
||||
</h1>
|
||||
|
||||
<div>
|
||||
<%= link_to(
|
||||
t("administrate.actions.show_resource", name: page.page_title),
|
||||
[namespace, page.resource],
|
||||
class: "button"
|
||||
) if accessible_action?(page.resource, :show) %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="main-content__body [&_.field-unit]:border-b [&_.field-unit]:border-n-weak [&_.field-unit]:pb-6">
|
||||
<%= render "form", page: page %>
|
||||
</section>
|
||||
@@ -1,98 +1,103 @@
|
||||
<%#
|
||||
# Show
|
||||
<%# The account page: a hero with the key facts, then one tab per concern. `page` is an Administrate::Page::Show. %>
|
||||
|
||||
This view is the template for the show page.
|
||||
It renders the attributes of a resource,
|
||||
as well as a link to its edit page.
|
||||
<% content_for(:title) { t("administrate.actions.show_resource", name: page.page_title) } %>
|
||||
<% content_for(:commands) { render "command_group", label: page.page_title, commands: record_commands(page.resource) } %>
|
||||
<%
|
||||
account = page.resource
|
||||
fields = page.attributes.values.flatten.index_by(&:name)
|
||||
users_count = fields["account_users"].data.size
|
||||
tabs = [
|
||||
{ key: "overview", label: t("super_admin.record.overview"), icon: "i-lucide-layout-list" },
|
||||
{ key: "users", label: t("super_admin.accounts.users"), icon: "i-lucide-users", count: users_count },
|
||||
({ key: "features", label: t("super_admin.features.title"), icon: "i-lucide-toggle-right" } if fields["all_features"]),
|
||||
({ key: "captain", label: t("super_admin.accounts.captain_models"), icon: "i-lucide-sparkles" } if fields["captain_models"]),
|
||||
{ key: "maintenance", label: t("super_admin.accounts.maintenance"), icon: "i-lucide-wrench" }
|
||||
].compact
|
||||
active = active_tab(tabs)
|
||||
%>
|
||||
|
||||
## Local variables:
|
||||
<%= render "hero",
|
||||
title: account.name,
|
||||
back: display_resource_name(page.resource_name),
|
||||
badges: render_field(fields["status"]),
|
||||
details: { "i-lucide-hash" => account.id, "i-lucide-languages" => account.locale },
|
||||
facts: [
|
||||
{ label: t("super_admin.accounts.users"), value: number_with_delimiter(users_count), icon: "i-lucide-users text-n-iris-10" },
|
||||
{ label: t("super_admin.accounts.conversations"), value: number_with_delimiter(fields["conversations"].to_s), icon: "i-lucide-messages-square text-n-amber-11" },
|
||||
{ label: t("super_admin.record.created"), value: l(account.created_at.to_date, format: "%b %-d, %Y"), icon: "i-lucide-calendar text-n-teal-10" },
|
||||
{ label: t("super_admin.record.last_updated"), value: t("super_admin.record.time_ago", time: time_ago_in_words(account.updated_at)).upcase_first, icon: "i-lucide-history text-n-blue-10" }
|
||||
] do %>
|
||||
<%= render("button", label: t("administrate.actions.edit"), url: [:edit, namespace, page.resource]) if accessible_action?(page.resource, :edit) %>
|
||||
<% end %>
|
||||
|
||||
- `page`:
|
||||
An instance of [Administrate::Page::Show][1].
|
||||
Contains methods for accessing the resource to be displayed on the page,
|
||||
as well as helpers for describing how each attribute of the resource
|
||||
should be displayed.
|
||||
<%= render "tabs", tabs: tabs, active: active %>
|
||||
|
||||
[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Show %>
|
||||
<div role="tabpanel" id="panel-overview" aria-labelledby="tab-overview" data-tab-panel="overview" <%= "hidden" unless active == "overview" %>>
|
||||
<div class="space-y-6 pt-6">
|
||||
<%= render "card", title: t("super_admin.record.profile") do %>
|
||||
<%= render "attributes", page: page, attributes: fields.values_at("id", "name", "locale", "status", "created_at", "updated_at") %>
|
||||
<% end %>
|
||||
|
||||
<% content_for(:title) do
|
||||
t("administrate.actions.show_resource", name: page.page_title)
|
||||
end %>
|
||||
<div class="grid grid-cols-1 items-start gap-6 lg:grid-cols-[repeat(auto-fit,minmax(20rem,1fr))]">
|
||||
<% if fields["limits"] %>
|
||||
<%= render "card", title: t("super_admin.accounts.limits") do %>
|
||||
<div class="px-4 text-body-main"><%= render_field fields["limits"], page: page %></div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<header class="main-content__header" role="banner">
|
||||
<h1 class="main-content__page-title">
|
||||
<%= content_for(:title) %>
|
||||
</h1>
|
||||
|
||||
<div>
|
||||
<%= if accessible_action?(page.resource, :edit)
|
||||
link_to("Edit", [:edit, namespace, page.resource], class: "button")
|
||||
end %>
|
||||
<%= render "card", title: t("super_admin.accounts.suspension_history") do %>
|
||||
<%= render_field fields["suspension_history"], page: page %>
|
||||
<% end %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="main-content__body">
|
||||
<div class="pr-16">
|
||||
<% page.attributes.each do |title, attributes| %>
|
||||
<% if title.present? && title == 'all_features' %>
|
||||
<% regular_features, premium_features =
|
||||
attributes.partition do |attr|
|
||||
!SuperAdmin::AccountFeaturesHelper.account_premium_features.include?(
|
||||
attr.data.keys.first,
|
||||
)
|
||||
end %>
|
||||
<% if fields["custom_attributes"]&.data.present? %>
|
||||
<%= render "card", title: t("super_admin.accounts.custom_attributes") do %>
|
||||
<dl class="-mb-px grid grid-cols-1 overflow-hidden rounded-b-xl sm:grid-cols-2">
|
||||
<% fields["custom_attributes"].data.each do |key, value| %>
|
||||
<div class="min-w-0 border-b border-n-weak px-4 py-3 sm:odd:border-e">
|
||||
<dt class="font-mono text-xs text-n-slate-10"><%= key %></dt>
|
||||
<dd class="mt-1 break-all text-body-main text-n-slate-12"><%= value.nil? ? "–" : value %></dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<% regular_features.each do |attribute| %>
|
||||
<div class="attribute-label" id="<%= attribute.name %>">
|
||||
<%= t(
|
||||
"helpers.label.#{resource_name}.#{attribute.name}",
|
||||
default: page.resource.class.human_attribute_name(attribute.name),
|
||||
) %>
|
||||
</div>
|
||||
<div role="tabpanel" id="panel-users" aria-labelledby="tab-users" data-tab-panel="users" <%= "hidden" unless active == "users" %>>
|
||||
<div class="space-y-6 pt-6">
|
||||
<%= render_field fields["account_users"], page: page %>
|
||||
<%= render "super_admin/shared/account_user_form", page: page, resource_type: "account" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="attribute-data attribute-data--<%=attribute.html_class%>"><%= render_field attribute, page: page %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if fields["all_features"] %>
|
||||
<div role="tabpanel" id="panel-features" aria-labelledby="tab-features" data-tab-panel="features" <%= "hidden" unless active == "features" %>>
|
||||
<div class="space-y-6 pt-6">
|
||||
<div class="text-body-main"><%= render_field fields["all_features"], page: page %></div>
|
||||
|
||||
<hr class="my-8 border-n-weak">
|
||||
|
||||
<div class="space-y-4">
|
||||
<% premium_features.each do |attribute| %>
|
||||
<div class="attribute-label" id="<%= attribute.name %>">
|
||||
<%= t(
|
||||
"helpers.label.#{resource_name}.#{attribute.name}",
|
||||
default: page.resource.class.human_attribute_name(attribute.name),
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<div class="attribute-data attribute-data--<%=attribute.html_class%>"><%= render_field attribute, page: page %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<fieldset class="<%= "field-unit--nested" if title.present? %>">
|
||||
<% if title.present? %>
|
||||
<legend><%= t "helpers.label.#{page.resource_name}.#{title}", default: title %></legend>
|
||||
<% end %>
|
||||
|
||||
<% attributes.each do |attribute| %>
|
||||
<div class="attribute-label" id="<%= attribute.name %>">
|
||||
<%= t(
|
||||
"helpers.label.#{resource_name}.#{attribute.name}",
|
||||
default: page.resource.class.human_attribute_name(attribute.name),
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<div class="attribute-data attribute-data--<%=attribute.html_class%>"><%= render_field attribute, page: page %></div>
|
||||
<% end %>
|
||||
</fieldset>
|
||||
<% if fields["manually_managed_features"] %>
|
||||
<%= render "card", title: t("super_admin.accounts.manually_managed_features"), meta: fields["manually_managed_features"].selected_features.size, collapsible: true do %>
|
||||
<div class="p-4 text-body-main"><%= render_field fields["manually_managed_features"], page: page %></div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= render 'super_admin/shared/account_user_form', page: page, namespace: namespace, resource_type: 'account' %>
|
||||
<% if fields["captain_models"] %>
|
||||
<div role="tabpanel" id="panel-captain" aria-labelledby="tab-captain" data-tab-panel="captain" <%= "hidden" unless active == "captain" %>>
|
||||
<div class="rounded-xl border border-n-weak bg-n-solid-1 mt-6 p-4">
|
||||
<h2 class="text-xs font-medium uppercase tracking-wider text-n-slate-10 mb-2" id="captain_models"><%= t("super_admin.accounts.captain_models") %></h2>
|
||||
<%= render_field fields["captain_models"], page: page %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= render partial: "seed_data", locals: { page: page } %>
|
||||
|
||||
<%= render partial: "reset_cache", locals: { page: page } %>
|
||||
<div role="tabpanel" id="panel-maintenance" aria-labelledby="tab-maintenance" data-tab-panel="maintenance" <%= "hidden" unless active == "maintenance" %>>
|
||||
<div class="rounded-xl border border-n-weak bg-n-solid-1 mt-6 divide-y divide-n-weak">
|
||||
<%= render "seed_data", page: page %>
|
||||
<%= render "reset_cache", page: page %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,100 +1,62 @@
|
||||
<% content_for(:title) do %>
|
||||
Configure Settings - <%= @config.titleize %>
|
||||
<% end %>
|
||||
<% content_for(:title) { t("super_admin.settings.configure_title", config: @config.titleize) } %>
|
||||
<% settings_page = settings_pages.map(&:last).find { |attrs| attrs['config_key'] == @config } || {} %>
|
||||
|
||||
<header class="main-content__header" role="banner">
|
||||
<h1 class="main-content__page-title" id="page-title">
|
||||
<%= content_for(:title) %>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.eye-icon.eye-hide path {
|
||||
d: path('M3 3l18 18M10.5 10.677a2 2 0 002.823 2.823M7.362 7.561C5.68 8.74 4.279 10.42 3 12c1.889 2.991 5.282 6 9 6 1.55 0 3.043-.523 4.395-1.35M12 6c4.008 0 6.701 3.009 9 6a15.66 15.66 0 01-1.078 1.5');
|
||||
}
|
||||
</style>
|
||||
|
||||
<section class="main-content__body">
|
||||
<%= form_with url: super_admin_app_config_url(config: @config) , method: :post do |form| %>
|
||||
<% @allowed_configs.each do |key| %>
|
||||
<div class="flex mb-8">
|
||||
<div class="field-unit__label">
|
||||
<%= form.label "app_config[#{key}]", @installation_configs[key]&.dig('display_title') || key %>
|
||||
</div>
|
||||
<div class="-mt-2 field-unit__field ">
|
||||
<% if @installation_configs[key]&.dig('type') == 'boolean' %>
|
||||
<%= form.select "app_config[#{key}]",
|
||||
[["True", true], ["False", false]],
|
||||
{ selected: ActiveModel::Type::Boolean.new.cast(@app_config[key]) },
|
||||
class: "mt-2 border border-slate-100 p-1 rounded-md"
|
||||
%>
|
||||
<% elsif @installation_configs[key]&.dig('type') == 'code' %>
|
||||
<%= form.text_area "app_config[#{key}]",
|
||||
value: @app_config[key],
|
||||
rows: 12,
|
||||
wrap: 'off',
|
||||
class: "mt-2 border font-mono text-xs border-slate-100 p-1 rounded-md overflow-scroll"
|
||||
%>
|
||||
<% elsif @installation_configs[key]&.dig('type') == 'secret' %>
|
||||
<div class="relative">
|
||||
<%= form.password_field "app_config[#{key}]",
|
||||
id: "app_config_#{key}",
|
||||
value: @app_config[key],
|
||||
class: "mt-2 border border-slate-100 p-1.5 pr-8 rounded-md w-full"
|
||||
%>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute reset-base !bg-white top-1/2 !outline-0 !text-n-slate-11 -translate-y-1/2 right-2 p-1 hover:!bg-n-slate-5 rounded-sm toggle-password"
|
||||
data-target="app_config_<%= key %>"
|
||||
>
|
||||
<svg class="eye-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5C5.63636 5 2 12 2 12C2 12 5.63636 19 12 19C18.3636 19 22 12 22 12C22 12 18.3636 5 12 5Z"/>
|
||||
<path d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<% elsif @installation_configs[key]&.dig('type') == 'select' && @installation_configs[key]&.dig('options').present? %>
|
||||
<%= form.select "app_config[#{key}]",
|
||||
@installation_configs[key]['options'].map { |val, label| [label, val] },
|
||||
{ selected: @app_config[key] },
|
||||
class: "mt-2 border border-slate-100 p-1 rounded-md"
|
||||
%>
|
||||
<% else %>
|
||||
<%= form.text_field "app_config[#{key}]", value: @app_config[key] %>
|
||||
<% end %>
|
||||
<%if @installation_configs[key]&.dig('description').present? %>
|
||||
<p class="pt-2 text-xs italic text-slate-400">
|
||||
<%= @installation_configs[key]&.dig('description') %>
|
||||
</p>
|
||||
<% end %>
|
||||
<%= render "section",
|
||||
title: settings_page['name'] || @config.titleize,
|
||||
description: settings_page['description'],
|
||||
icon: settings_page['icon'] || 'i-lucide-sliders-horizontal' do %>
|
||||
<%= form_with url: super_admin_app_config_url(config: @config), method: :post do |form| %>
|
||||
<%# Not on the form itself: its hidden token input would count as the first row and give the next one a second top border. %>
|
||||
<div class="rounded-xl border border-n-weak bg-n-solid-1 divide-y divide-n-weak">
|
||||
<% @allowed_configs.each do |key| %>
|
||||
<% config = @installation_configs[key] || {} %>
|
||||
<% field_id = "app_config_#{key}" %>
|
||||
<div class="<%= class_names("grid gap-x-8 gap-y-1.5 px-5 py-4", "sm:grid-cols-2" => config['type'] != 'code') %>">
|
||||
<div class="text-label text-n-slate-12">
|
||||
<%= form.label "app_config[#{key}]", config['display_title'] || key, for: field_id %>
|
||||
<% if config['description'].present? %>
|
||||
<p class="mt-0.5 max-w-xl text-label-small text-n-slate-11"><%= config['description'] %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<% if config['type'] == 'boolean' %>
|
||||
<%= form.select "app_config[#{key}]",
|
||||
[["True", true], ["False", false]],
|
||||
{ selected: ActiveModel::Type::Boolean.new.cast(@app_config[key]) },
|
||||
id: field_id, class: ui(:select) %>
|
||||
<% elsif config['type'] == 'code' %>
|
||||
<%= form.text_area "app_config[#{key}]",
|
||||
value: @app_config[key],
|
||||
rows: 12,
|
||||
wrap: 'off',
|
||||
id: field_id,
|
||||
class: ui(:input, "overflow-scroll font-mono text-xs") %>
|
||||
<% elsif config['type'] == 'secret' %>
|
||||
<div class="relative">
|
||||
<%= form.password_field "app_config[#{key}]", value: @app_config[key], id: field_id, class: ui(:input, "pe-10") %>
|
||||
<%= render "icon_button",
|
||||
extra: "absolute end-1 top-1/2 -translate-y-1/2",
|
||||
aria: { label: t("super_admin.actions.toggle_secret") },
|
||||
data: { reveal: field_id } do %>
|
||||
<span class="i-lucide-eye size-4"></span>
|
||||
<span class="i-lucide-eye-off hidden size-4"></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% elsif config['type'] == 'select' && config['options'].present? %>
|
||||
<%= form.select "app_config[#{key}]",
|
||||
config['options'].map { |val, label| [label, val] },
|
||||
{ selected: @app_config[key] },
|
||||
id: field_id, class: ui(:select) %>
|
||||
<% else %>
|
||||
<%= form.text_field "app_config[#{key}]", value: @app_config[key], id: field_id, class: ui(:input) %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="sticky bottom-0 z-10 flex items-center gap-2 rounded-b-xl bg-n-solid-1 px-5 py-3" data-form-actions>
|
||||
<%= render "button", label: t("super_admin.actions.save_changes"), submit: form %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="form-actions">
|
||||
<%= form.submit "Submit" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<% content_for :javascript do %>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.toggle-password').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const targetId = button.dataset.target;
|
||||
const input = document.getElementById(targetId);
|
||||
const type = input.type === 'password' ? 'text' : 'password';
|
||||
input.type = type;
|
||||
|
||||
// Toggle icon
|
||||
const svg = button.querySelector('.eye-icon');
|
||||
if (type === 'password') {
|
||||
svg.classList.remove('eye-hide')
|
||||
} else {
|
||||
svg.classList.add('eye-hide')
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<% end %>
|
||||
|
||||
48
app/views/super_admin/application/_account_menu.html.erb
Normal file
48
app/views/super_admin/application/_account_menu.html.erb
Normal file
@@ -0,0 +1,48 @@
|
||||
<%# locals: (expanded: false) -%>
|
||||
<%
|
||||
admin = current_super_admin
|
||||
avatar = rails_storage_proxy_path(admin.avatar.representation(resize_to_fill: [250, nil])) if admin.avatar.attached? && admin.avatar.representable?
|
||||
themes = {
|
||||
system: [t("super_admin.navigation.theme.system"), "i-lucide-monitor"],
|
||||
light: [t("super_admin.navigation.theme.light"), "i-lucide-sun"],
|
||||
dark: [t("super_admin.navigation.theme.dark"), "i-lucide-moon"]
|
||||
}
|
||||
avatar_size = expanded ? "size-7" : "size-9"
|
||||
%>
|
||||
<details class="relative" data-dropdown>
|
||||
<summary class="<%= class_names("cursor-pointer list-none transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-n-brand [&::-webkit-details-marker]:hidden",
|
||||
expanded ? "flex items-center gap-2.5 rounded-lg px-2 py-1 text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12" : "block rounded-full") %>"
|
||||
aria-label="<%= t("super_admin.navigation.account") %>">
|
||||
<% if avatar %>
|
||||
<%= image_tag avatar, alt: "", class: "#{avatar_size} shrink-0 rounded-full border border-n-weak object-cover" %>
|
||||
<% else %>
|
||||
<span class="grid <%= avatar_size %> shrink-0 place-items-center rounded-full border border-n-weak bg-n-slate-3 text-label-small uppercase text-n-slate-11"><%= admin.name.to_s.first %></span>
|
||||
<% end %>
|
||||
<% if expanded %>
|
||||
<span class="min-w-0 flex-1 truncate text-label text-n-slate-12"><%= admin.name %></span>
|
||||
<span class="i-lucide-chevrons-up-down size-4 shrink-0 text-n-slate-10"></span>
|
||||
<% end %>
|
||||
</summary>
|
||||
<div class="<%= class_names("absolute z-40 max-w-[calc(100vw-2rem)] rounded-xl border border-n-weak bg-n-solid-1 p-1 shadow-lg",
|
||||
expanded ? "inset-x-0 bottom-full mb-2 motion-safe:animate-fade-in-up" : "end-0 top-full mt-2 w-64 motion-safe:animate-fade-in") %>">
|
||||
<div class="border-b border-n-weak px-2.5 py-2">
|
||||
<p class="truncate text-label text-n-slate-12"><%= admin.name %></p>
|
||||
<p class="truncate text-label-small text-n-slate-11"><%= admin.email %></p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 px-2.5 py-2">
|
||||
<span class="text-body-main text-n-slate-11"><%= t("super_admin.navigation.theme.label") %></span>
|
||||
<div class="flex gap-0.5 rounded-lg border border-n-weak p-0.5" role="group" aria-label="<%= t("super_admin.navigation.theme.label") %>">
|
||||
<% themes.each do |value, (label, icon)| %>
|
||||
<button type="button" class="grid size-7 place-items-center rounded-md text-n-slate-10 transition-colors hover:text-n-slate-12 aria-pressed:bg-n-alpha-2 aria-pressed:text-n-slate-12 touch:size-9" data-theme-value="<%= value %>" aria-pressed="false" title="<%= label %>">
|
||||
<span class="<%= icon %> size-4"></span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-0.5 border-t border-n-weak pt-1">
|
||||
<%= render "nav_item", item: { label: t("super_admin.navigation.profile"), url: super_admin_user_path(admin), icon: "i-lucide-user" } %>
|
||||
<%= render "nav_item", item: { label: t("super_admin.navigation.agent_dashboard"), url: "/", icon: "i-lucide-message-circle" } %>
|
||||
<%= render "nav_item", item: { label: t("super_admin.navigation.logout"), url: super_admin_logout_path, icon: "i-lucide-log-out" } %>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
4
app/views/super_admin/application/_alert.html.erb
Normal file
4
app/views/super_admin/application/_alert.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<%# locals: (extra: nil, **attributes) -%>
|
||||
<%= tag.div class: class_names("rounded-lg border border-n-ruby-6 bg-n-ruby-2 px-3.5 py-2.5 text-body-main text-n-ruby-12 [&_a]:underline", extra), **attributes do %>
|
||||
<%= yield %>
|
||||
<% end %>
|
||||
5
app/views/super_admin/application/_attribute.html.erb
Normal file
5
app/views/super_admin/application/_attribute.html.erb
Normal file
@@ -0,0 +1,5 @@
|
||||
<%# locals: (label:, stacked: false, centered: false) -%>
|
||||
<div class="<%= class_names("flex min-w-0 gap-4 border-b border-n-weak py-2.5", stacked ? "col-span-full flex-col gap-2" : (centered ? "items-center" : "items-baseline")) %>">
|
||||
<%= tag.dt label, class: class_names("text-body-main text-n-slate-11", "w-32 shrink-0" => !stacked) %>
|
||||
<dd class="min-w-0 flex-1 break-words text-body-main text-n-slate-12 [&_a:not([class])]:text-n-blue-11 hover:[&_a:not([class])]:underline"><%= yield %></dd>
|
||||
</div>
|
||||
12
app/views/super_admin/application/_attributes.html.erb
Normal file
12
app/views/super_admin/application/_attributes.html.erb
Normal file
@@ -0,0 +1,12 @@
|
||||
<%# locals: (page:, attributes:) -%>
|
||||
<dl class="-mb-px grid grid-cols-1 px-4 sm:grid-cols-2 sm:gap-x-8 xl:grid-cols-3">
|
||||
<% attributes.each do |attribute| %>
|
||||
<%= render "attribute",
|
||||
label: t("helpers.label.#{page.resource_name}.#{attribute.name}", default: page.resource.class.human_attribute_name(attribute.name)),
|
||||
stacked: attribute.html_class.in?(%w[has-many has-one text serialized-field]),
|
||||
centered: attribute.html_class == "avatar-field" do %>
|
||||
<%= render_field attribute, page: page %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= yield %>
|
||||
</dl>
|
||||
5
app/views/super_admin/application/_back_link.html.erb
Normal file
5
app/views/super_admin/application/_back_link.html.erb
Normal file
@@ -0,0 +1,5 @@
|
||||
<%# locals: (label:) -%>
|
||||
<%= link_to({ action: :index }, class: "mb-3 inline-flex items-center gap-1.5 text-body-main text-n-slate-11 hover:text-n-slate-12") do %>
|
||||
<span class="i-lucide-arrow-left size-4"></span>
|
||||
<%= label %>
|
||||
<% end %>
|
||||
19
app/views/super_admin/application/_badge.html.erb
Normal file
19
app/views/super_admin/application/_badge.html.erb
Normal file
@@ -0,0 +1,19 @@
|
||||
<%# locals: (label:, tone: nil) -%>
|
||||
<%
|
||||
dot = "before:size-1.5 before:rounded-full before:bg-current before:content-['']"
|
||||
tones = {
|
||||
neutral: "border-n-slate-6 bg-n-slate-3 text-n-slate-11",
|
||||
success: "#{dot} border-n-teal-6 bg-n-teal-3 text-n-teal-11",
|
||||
danger: "#{dot} border-n-ruby-6 bg-n-ruby-3 text-n-ruby-11",
|
||||
warning: "#{dot} border-n-amber-6 bg-n-amber-3 text-n-amber-11",
|
||||
info: "border-n-blue-6 bg-n-blue-3 text-n-blue-11",
|
||||
accent: "border-n-violet-6 bg-n-violet-3 text-n-violet-11"
|
||||
}
|
||||
# Without a tone the label picks it: these are the values select fields show (account status, role, user type, banner type).
|
||||
value_tones = {
|
||||
"Active" => :success, "Suspended" => :danger, "Error" => :danger, "Warning" => :warning,
|
||||
"Info" => :info, "Administrator" => :info, "Super Admin" => :accent, "Feature Announcement" => :accent
|
||||
}
|
||||
tone ||= value_tones.fetch(label.to_s, :neutral)
|
||||
%>
|
||||
<span class="<%= class_names("inline-flex h-5 items-center gap-1 whitespace-nowrap rounded-md border px-1.5 text-label-small", tones.fetch(tone)) %>"><%= label %></span>
|
||||
28
app/views/super_admin/application/_brand.html.erb
Normal file
28
app/views/super_admin/application/_brand.html.erb
Normal file
@@ -0,0 +1,28 @@
|
||||
<%
|
||||
version = Chatwoot.config[:version]
|
||||
# Internal::CheckNewVersionsJob keeps the newest release here.
|
||||
latest_version = begin
|
||||
Redis::Alfred.get(Redis::Alfred::LATEST_CHATWOOT_VERSION).presence
|
||||
rescue Redis::BaseConnectionError
|
||||
nil
|
||||
end
|
||||
update_available = latest_version && Gem::Version.new(latest_version) > Gem::Version.new(version)
|
||||
%>
|
||||
<div class="flex h-14 shrink-0 items-center gap-3">
|
||||
<%= link_to image_tag("/brand-assets/logo_thumbnail.svg", alt: "", class: "size-9"), super_admin_root_path, class: "shrink-0", tabindex: -1, aria: { hidden: true } %>
|
||||
<div class="min-w-0 whitespace-nowrap">
|
||||
<div class="flex items-baseline gap-1.5 leading-5">
|
||||
<%= link_to "Chatwoot", super_admin_root_path, class: "text-base font-medium text-n-slate-12" %>
|
||||
<%= link_to "https://github.com/chatwoot/chatwoot/releases/tag/v#{version}",
|
||||
target: "_blank", rel: "noopener noreferrer",
|
||||
title: update_available ? t("super_admin.navigation.update_available", version: latest_version) : t("super_admin.navigation.release_notes"),
|
||||
class: "inline-flex items-center gap-1 text-label-small tabular-nums text-n-slate-10 transition-colors hover:text-n-slate-12 hover:underline" do %>
|
||||
v<%= version %>
|
||||
<% if update_available %>
|
||||
<span class="size-1.5 rounded-full bg-n-amber-9"></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="text-label-small leading-4 text-n-slate-10"><%= t("super_admin.navigation.suffix") %></div>
|
||||
</div>
|
||||
</div>
|
||||
30
app/views/super_admin/application/_button.html.erb
Normal file
30
app/views/super_admin/application/_button.html.erb
Normal file
@@ -0,0 +1,30 @@
|
||||
<%# locals: (label: nil, variant: :primary, size: :default, icon: nil, icon_end: nil, url: nil, post_to: nil, submit: nil, as: :button, extra: nil, **options) -%>
|
||||
<%
|
||||
variants = {
|
||||
primary: "border-transparent bg-n-brand text-white hover:bg-n-blue-10",
|
||||
secondary: "border-n-slate-6 bg-n-solid-1 text-n-slate-12 hover:bg-n-slate-3",
|
||||
danger: "border-n-ruby-6 bg-n-solid-1 text-n-ruby-11 hover:bg-n-ruby-3",
|
||||
faded: "border-transparent bg-n-alpha-2 text-n-slate-12 hover:bg-n-slate-5",
|
||||
faded_danger: "border-transparent bg-n-alpha-2 text-n-slate-12 hover:bg-n-ruby-3 hover:text-n-ruby-11"
|
||||
}
|
||||
sizes = {
|
||||
default: "h-9 px-3.5 text-button touch:h-11",
|
||||
icon: "size-8 touch:size-10"
|
||||
}
|
||||
button = %w[
|
||||
inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border
|
||||
transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2
|
||||
focus-visible:outline-n-brand disabled:cursor-not-allowed disabled:opacity-50
|
||||
].join(" ")
|
||||
classes = class_names(button, sizes.fetch(size), variants.fetch(variant), extra)
|
||||
content = safe_join([(tag.span(class: icon) if icon), label, (tag.span(class: icon_end) if icon_end)].compact)
|
||||
%>
|
||||
<% if submit %>
|
||||
<%= submit.submit label, class: classes, **options %>
|
||||
<% elsif post_to %>
|
||||
<%= button_to content, post_to, class: classes, **options %>
|
||||
<% elsif url %>
|
||||
<%= link_to content, url, class: classes, **options %>
|
||||
<% else %>
|
||||
<%= content_tag as, content, class: classes, type: ("button" if as == :button), **options %>
|
||||
<% end %>
|
||||
18
app/views/super_admin/application/_card.html.erb
Normal file
18
app/views/super_admin/application/_card.html.erb
Normal file
@@ -0,0 +1,18 @@
|
||||
<%# locals: (title:, meta: nil, data: {}, collapsible: false) -%>
|
||||
<%= content_tag (collapsible ? :details : :section), class: "group/card rounded-xl border border-n-weak bg-n-solid-1", data: data do %>
|
||||
<%= content_tag (collapsible ? :summary : :div),
|
||||
class: class_names("flex min-h-11 items-center justify-between gap-3 border-n-weak px-4 py-2",
|
||||
"border-b" => !collapsible,
|
||||
"cursor-pointer list-none group-open/card:border-b [&::-webkit-details-marker]:hidden" => collapsible) do %>
|
||||
<h2 class="text-xs font-medium uppercase tracking-wider text-n-slate-10"><%= title %></h2>
|
||||
<% if meta || collapsible %>
|
||||
<span class="flex items-center gap-2 text-label-small tabular-nums text-n-slate-11">
|
||||
<%= meta %>
|
||||
<% if collapsible %>
|
||||
<span class="i-lucide-chevron-down size-4 text-n-slate-10 transition-transform group-open/card:rotate-180"></span>
|
||||
<% end %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= yield %>
|
||||
<% end %>
|
||||
14
app/views/super_admin/application/_check_tile.html.erb
Normal file
14
app/views/super_admin/application/_check_tile.html.erb
Normal file
@@ -0,0 +1,14 @@
|
||||
<%# locals: (label:, icon: nil, help_url: nil, premium: false, disabled: false) -%>
|
||||
<label class="<%= class_names("flex min-h-10 items-center gap-2.5 rounded-lg border border-n-weak px-3 py-1.5 has-[:checked]:border-n-blue-6 has-[:checked]:bg-n-blue-2", disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-n-alpha-2") %>" data-filter-item>
|
||||
<% if icon %>
|
||||
<span class="<%= icon %> size-4 shrink-0 text-n-slate-11"></span>
|
||||
<% end %>
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="truncate"><%= label %></span>
|
||||
<% if premium %>
|
||||
<span class="i-lucide-crown size-3.5 shrink-0 text-n-amber-11"></span>
|
||||
<% end %>
|
||||
</span>
|
||||
<%= render "help_link", url: help_url if help_url %>
|
||||
<%= yield %>
|
||||
</label>
|
||||
13
app/views/super_admin/application/_chip.html.erb
Normal file
13
app/views/super_admin/application/_chip.html.erb
Normal file
@@ -0,0 +1,13 @@
|
||||
<%# locals: (label:, selected:, url: nil, data: {}) -%>
|
||||
<%
|
||||
chip = %w[
|
||||
inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg border border-n-slate-6 bg-n-solid-1 px-3 text-body-main text-n-slate-11
|
||||
hover:bg-n-alpha-2 hover:text-n-slate-12 aria-[current=true]:border-n-blue-7 aria-[current=true]:bg-n-blue-3
|
||||
aria-[current=true]:text-n-blue-11 aria-pressed:border-n-blue-7 aria-pressed:bg-n-blue-3 aria-pressed:text-n-blue-11 touch:h-11
|
||||
].join(" ")
|
||||
%>
|
||||
<% if url %>
|
||||
<%= link_to label, url, class: chip, aria: { current: selected } %>
|
||||
<% else %>
|
||||
<%= tag.button label, type: "button", class: "#{chip} tabular-nums", aria: { pressed: selected }, data: data %>
|
||||
<% end %>
|
||||
@@ -14,82 +14,108 @@ to display a collection of resources in an HTML table.
|
||||
An ActiveModel::Relation collection of resources to be displayed in the table.
|
||||
By default, the number of resources is limited by pagination
|
||||
or by a hard limit to prevent excessive page load times
|
||||
- `page` and `collection_field_name`:
|
||||
The current page and the param namespace its sort links use.
|
||||
Administrate's has_many field also passes `table_title` and `resource_class`, which this partial does not use.
|
||||
|
||||
[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Collection
|
||||
%>
|
||||
|
||||
<table aria-labelledby="<%= table_title %>">
|
||||
<thead>
|
||||
<tr>
|
||||
<% collection_presenter.attribute_types.each do |attr_name, attr_type| %>
|
||||
<th class="cell-label
|
||||
cell-label--<%= attr_type.html_class %>
|
||||
cell-label--<%= collection_presenter.ordered_html_class(attr_name) %>"
|
||||
scope="col"
|
||||
role="columnheader"
|
||||
aria-sort="<%= sort_order(collection_presenter.ordered_html_class(attr_name)) %>">
|
||||
<%= link_to(sanitized_order_params(page, collection_field_name).merge(
|
||||
collection_presenter.order_params_for(attr_name, key: collection_field_name)
|
||||
)) do %>
|
||||
<%= t(
|
||||
"helpers.label.#{collection_presenter.resource_name}.#{attr_name}",
|
||||
default: attr_name.to_s,
|
||||
).titleize %>
|
||||
<% if collection_presenter.ordered_by?(attr_name) %>
|
||||
<span class="cell-label__sort-indicator cell-label__sort-indicator--<%= collection_presenter.ordered_html_class(attr_name) %>">
|
||||
<svg aria-hidden="true">
|
||||
<use xlink:href="#icon-up-caret" />
|
||||
</svg>
|
||||
</span>
|
||||
<%
|
||||
resource_name = collection_presenter.resource_name
|
||||
# The avatar has no column of its own: it sits in front of the name.
|
||||
columns = collection_presenter.attribute_types.reject { |_name, type| type == AvatarField }
|
||||
cell_styles = { "number" => "font-mono text-xs text-n-slate-11", "count-field" => "tabular-nums", "date-time" => "tabular-nums text-n-slate-11" }
|
||||
%>
|
||||
|
||||
<div class="rounded-xl border border-n-weak bg-n-solid-1 overflow-hidden">
|
||||
<div class="relative overflow-x-auto">
|
||||
<%= render "table", caption: display_resource_name(resource_name) do %>
|
||||
<thead>
|
||||
<tr>
|
||||
<% columns.each_key do |attr_name| %>
|
||||
<% direction = collection_presenter.ordered_html_class(attr_name) %>
|
||||
<th scope="col" aria-sort="<%= { asc: "ascending", desc: "descending" }.fetch(direction, "none") %>">
|
||||
<%= link_to(
|
||||
sanitized_order_params(page, collection_field_name).merge(collection_presenter.order_params_for(attr_name, key: collection_field_name)),
|
||||
class: class_names("inline-flex items-center gap-1 hover:text-n-slate-12", "text-n-slate-12" => direction.present?)
|
||||
) do %>
|
||||
<%= t("helpers.label.#{resource_name}.#{attr_name}", default: attr_name.to_s).titleize %>
|
||||
<% if direction.present? %>
|
||||
<span class="<%= direction == :asc ? "i-lucide-arrow-up" : "i-lucide-arrow-down" %> size-3.5"></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</th>
|
||||
<% end %>
|
||||
<th scope="col"><span class="sr-only"><%= t("super_admin.collection.actions") %></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<% resources.each do |resource| %>
|
||||
<%# A SuperAdmin is a User without routes of its own. %>
|
||||
<% record = resource.is_a?(User) ? resource.becomes(User) : resource %>
|
||||
<% show_path = polymorphic_path([namespace, record]) if existing_action?(resource_name, :show) %>
|
||||
<%# `js-table-row` and `data-url` are what Administrate's table.js opens a row with. %>
|
||||
<%= tag.tr class: class_names("js-table-row", "cursor-pointer hover:bg-n-alpha-2 focus-visible:bg-n-alpha-2 focus-visible:outline-none" => show_path),
|
||||
tabindex: ("0" if show_path), role: ("link" if show_path), data: { url: show_path } do %>
|
||||
<% attributes = collection_presenter.attributes_for(resource) %>
|
||||
<% avatar = attributes.find { |attribute| attribute.is_a?(AvatarField) } %>
|
||||
<% (attributes - [avatar]).each do |attribute| %>
|
||||
<td class="<%= class_names("max-w-80 truncate", cell_styles[attribute.html_class]) %>">
|
||||
<% cell = capture do %>
|
||||
<% if attribute.name == "name" && avatar %>
|
||||
<%= render_field avatar %>
|
||||
<% elsif attribute.name == "name" %>
|
||||
<span class="me-2 inline-grid size-7 place-items-center rounded-lg border border-n-weak bg-n-slate-3 align-middle text-xs font-semibold uppercase text-n-slate-11"><%= attribute.data.to_s.first %></span>
|
||||
<% end %>
|
||||
<%= render_field attribute %>
|
||||
<% end %>
|
||||
<%# The row is the keyboard target; cell links only carry the click, and a cell with its own link or button is left alone. %>
|
||||
<% if show_path && authorized_action?(resource, :show) && cell.present? && !cell.match?(/<(a|button)[\s>]/) %>
|
||||
<a href="<%= show_path %>" class="text-n-slate-12" tabindex="-1"><%= cell %></a>
|
||||
<% else %>
|
||||
<%= cell %>
|
||||
<% end %>
|
||||
</td>
|
||||
<% end %>
|
||||
|
||||
<td class="w-px">
|
||||
<%# The icons let clicks through to the link: Administrate's table.js opens the row unless the click target has an href. %>
|
||||
<div class="flex justify-end gap-1.5">
|
||||
<%= render(
|
||||
"button",
|
||||
icon: "i-lucide-pencil pointer-events-none size-4",
|
||||
url: [:edit, namespace, record],
|
||||
variant: :faded,
|
||||
size: :icon,
|
||||
title: t("administrate.actions.edit"),
|
||||
aria: { label: t("administrate.actions.edit") }
|
||||
) if existing_action?(resource_name, :edit) && authorized_action?(resource, :edit) %>
|
||||
<%= render(
|
||||
"button",
|
||||
icon: "i-lucide-trash-2 pointer-events-none size-4",
|
||||
url: [namespace, record],
|
||||
variant: :faded_danger,
|
||||
size: :icon,
|
||||
title: t("administrate.actions.destroy"),
|
||||
aria: { label: t("administrate.actions.destroy") },
|
||||
method: :delete,
|
||||
data: { confirm: t("administrate.actions.confirm") }
|
||||
) if existing_action?(resource_name, :destroy) && authorized_action?(resource, :destroy) %>
|
||||
</div>
|
||||
</td>
|
||||
<% end %>
|
||||
</th>
|
||||
<% end %>
|
||||
<% [existing_action?(collection_presenter.resource_name, :edit),
|
||||
existing_action?(collection_presenter.resource_name, :destroy)].count(true).times do %>
|
||||
<th scope="col"></th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<% resources.each do |resource| %>
|
||||
<tr class="js-table-row"
|
||||
tabindex="0"
|
||||
<% if existing_action? collection_presenter.resource_name, :show %>
|
||||
<%= %(role=link data-url=#{polymorphic_path([namespace, resource])}) %>
|
||||
<% end %>
|
||||
>
|
||||
<% collection_presenter.attributes_for(resource).each do |attribute| %>
|
||||
<td class="cell-data cell-data--<%= attribute.html_class %>">
|
||||
<% if authorized_action? resource, :show -%>
|
||||
<a href="<%= polymorphic_path([namespace, resource]) -%>"
|
||||
class="action-show"
|
||||
>
|
||||
<%= render_field attribute %>
|
||||
</a>
|
||||
<% end -%>
|
||||
</td>
|
||||
<% end %>
|
||||
|
||||
<% if existing_action? collection_presenter.resource_name, :edit %>
|
||||
<td><%= link_to(
|
||||
t("administrate.actions.edit"),
|
||||
[:edit, namespace, resource],
|
||||
class: "action-edit",
|
||||
) if authorized_action? resource, :edit%></td>
|
||||
<% if resources.empty? %>
|
||||
<tr>
|
||||
<td colspan="<%= columns.size + 1 %>" class="!py-10 text-center text-n-slate-11">
|
||||
<%= t("super_admin.collection.empty", resource: display_resource_name(resource_name).downcase) %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
||||
<% if existing_action? collection_presenter.resource_name, :destroy %>
|
||||
<td><%= link_to(
|
||||
t("administrate.actions.destroy"),
|
||||
[namespace, resource],
|
||||
class: "text-color-red",
|
||||
method: :delete,
|
||||
data: { confirm: t("administrate.actions.confirm") }
|
||||
) if authorized_action? resource, :destroy %></td>
|
||||
<% end %>
|
||||
</tr>
|
||||
</tbody>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
21
app/views/super_admin/application/_command.html.erb
Normal file
21
app/views/super_admin/application/_command.html.erb
Normal file
@@ -0,0 +1,21 @@
|
||||
<%# locals: (command:) -%>
|
||||
<%
|
||||
row = class_names("flex min-h-9 w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 text-start text-body-main group-aria-selected:bg-n-alpha-2 touch:min-h-11",
|
||||
"hover:bg-n-alpha-2" => command[:method])
|
||||
content = capture do
|
||||
%>
|
||||
<span class="<%= command[:icon] %> size-4 shrink-0 text-n-slate-10"></span>
|
||||
<span class="<%= class_names("min-w-0 flex-1 truncate", command[:danger] ? "text-n-ruby-11" : "text-n-slate-12") %>"><%= command[:label] %></span>
|
||||
<% if command[:hint] %>
|
||||
<span class="max-w-[50%] shrink-0 truncate text-label-small tabular-nums text-n-slate-10"><%= command[:hint] %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= tag.div role: "option", class: "group", aria: { selected: false }, data: { command_item: true, command_method: command[:method] } do %>
|
||||
<% if command[:method] %>
|
||||
<%= button_to command[:url], method: command[:method], class: row, tabindex: -1, form: command[:form] do %><%= content %><% end %>
|
||||
<% elsif command[:url] %>
|
||||
<%= link_to content, command[:url], class: row, tabindex: -1 %>
|
||||
<% else %>
|
||||
<%= tag.button content, type: "button", class: row, tabindex: -1, data: command[:data] %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
26
app/views/super_admin/application/_command_bar.html.erb
Normal file
26
app/views/super_admin/application/_command_bar.html.erb
Normal file
@@ -0,0 +1,26 @@
|
||||
<%# Opened with ⌘K / Ctrl+K or any `data-dialog-open="command-bar"` control. A record page adds its actions with `content_for(:commands)`. %>
|
||||
<dialog id="command-bar" aria-label="<%= t("super_admin.command_bar.label") %>" data-command-bar
|
||||
class="mb-auto mt-4 w-[calc(100%-2rem)] max-w-xl overflow-hidden rounded-2xl border border-n-weak bg-n-solid-1 p-0 text-n-slate-12 shadow-xl backdrop:bg-modal-backdrop-light open:animate-sheet-in motion-reduce:animate-none dark:backdrop:bg-modal-backdrop-dark sm:mt-24">
|
||||
<div class="flex h-12 items-center gap-2.5 border-b border-n-weak px-4">
|
||||
<span class="i-lucide-search size-4 shrink-0 text-n-slate-10"></span>
|
||||
<input type="text" class="min-w-0 flex-1 border-0 bg-transparent p-0 text-body-main text-n-slate-12 outline-none placeholder:text-n-slate-10 focus:ring-0 touch:text-base"
|
||||
role="combobox" aria-expanded="true" aria-controls="command-bar-list" aria-activedescendant="command-bar-selected" aria-label="<%= t("super_admin.command_bar.placeholder") %>"
|
||||
placeholder="<%= t("super_admin.command_bar.placeholder") %>" autocomplete="off" spellcheck="false" autofocus data-command-input>
|
||||
<%= render "kbd", label: "esc" %>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[60dvh] overflow-y-auto px-2 pb-2" id="command-bar-list" role="listbox" aria-label="<%= t("super_admin.command_bar.label") %>">
|
||||
<%= content_for(:commands) %>
|
||||
<% command_groups.each do |group| %>
|
||||
<%= render "command_group", group %>
|
||||
<% end %>
|
||||
<%# Stays out of the way while a command still matches. %>
|
||||
<p class="px-3 py-8 text-center text-body-main text-n-slate-11 [#command-bar-list:has([data-command-item]:not([hidden]))_&]:hidden"><%= t("super_admin.command_bar.empty") %></p>
|
||||
</div>
|
||||
|
||||
<footer class="flex items-center gap-4 border-t border-n-weak px-4 py-2 text-label-small text-n-slate-10 touch:hidden">
|
||||
<% { "↑↓" => t("super_admin.command_bar.navigate"), "↵" => t("super_admin.command_bar.select"), "esc" => t("super_admin.actions.close") }.each do |key, label| %>
|
||||
<span class="inline-flex items-center gap-1.5"><%= render "kbd", label: key %><%= label %></span>
|
||||
<% end %>
|
||||
</footer>
|
||||
</dialog>
|
||||
@@ -0,0 +1,5 @@
|
||||
<%# locals: (label:, commands:) -%>
|
||||
<div role="group" aria-label="<%= label %>" data-command-group="<%= label %>">
|
||||
<p class="px-3 pb-1 pt-3 text-xs font-medium uppercase tracking-wider text-n-slate-10" aria-hidden="true"><%= label %></p>
|
||||
<%= render partial: "command", collection: commands, as: :command %>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user