Merge branch 'main' into breakout-role-utils

This commit is contained in:
Dessalines 2023-06-19 10:04:19 -04:00 committed by GitHub
commit 02ffa85b58
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 2252 additions and 2104 deletions

View file

@ -3,11 +3,12 @@
"env": {
"browser": true
},
"plugins": ["@typescript-eslint"],
"plugins": ["@typescript-eslint", "jsx-a11y"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:inferno/recommended"
"plugin:inferno/recommended",
"plugin:jsx-a11y/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
@ -20,6 +21,16 @@
"@typescript-eslint/explicit-module-boundary-types": 0,
"@typescript-eslint/no-empty-function": 0,
"arrow-body-style": 0,
"jsx-a11y/alt-text": 1,
"jsx-a11y/anchor-is-valid": 1,
"jsx-a11y/aria-activedescendant-has-tabindex": 1,
"jsx-a11y/aria-role": 1,
"jsx-a11y/click-events-have-key-events": 1,
"jsx-a11y/iframe-has-title": 1,
"jsx-a11y/interactive-supports-focus": 1,
"jsx-a11y/no-redundant-roles": 1,
"jsx-a11y/no-static-element-interactions": 1,
"jsx-a11y/role-has-required-aria-props": 1,
"curly": 0,
"eol-last": 0,
"eqeqeq": 0,

View file

@ -8,7 +8,7 @@ body:
value: |
Found a bug? Please fill out the sections below. 👍
Thanks for taking the time to fill out this bug report!
For backend issues, use [lemmy](https://github.com/LemmyNet/lemmy)
For backend issues, use [lemmy](https://github.com/LemmyNet/lemmy/issues/new/choose)
- type: checkboxes
attributes:
label: Requirements

View file

@ -1,16 +1,16 @@
name: "\U0001F680 Feature request"
description: Suggest an idea for improving Lemmy
description: Suggest an idea for improving Lemmy's UI
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Have a suggestion about Lemmy's UI?
For backend issues, use [lemmy](https://github.com/LemmyNet/lemmy)
For backend issues, use [lemmy](https://github.com/LemmyNet/lemmy/issues/new/choose)
- type: checkboxes
attributes:
label: Requirements
description: Before you create a bug report please do the following.
description: Before you create a feature request please do the following.
options:
- label: Is this a feature request? For questions or discussions use https://lemmy.ml/c/lemmy_support
required: true

View file

@ -1,2 +1,3 @@
src/shared/translations
lemmy-translations
lemmy-translations
src/assets/css/themes/*.css

View file

@ -106,6 +106,7 @@
"bootstrap-v4": "npm:bootstrap@^4.6.2",
"eslint": "^8.40.0",
"eslint-plugin-inferno": "^7.32.2",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-prettier": "^4.2.1",
"husky": "^8.0.3",
"import-sort-style-module": "^6.0.0",

View file

@ -1,108 +1,13 @@
$white: #fff;
$gray-100: #f8f9fa;
$gray-200: #ebebeb;
$gray-300: #dee2e6;
$gray-400: #ced4da;
$gray-500: #adb5bd;
$gray-600: #888;
$gray-700: #444;
$gray-800: #303030;
$gray-900: #222;
$black: #000;
$blue: #375a7f;
$indigo: #6610f2;
$purple: #6f42c1;
$pink: #e83e8c;
$red: #e74c3c;
$orange: #fd7e14;
$yellow: #f39c12;
$green: #00bc8c;
$teal: #20c997;
$cyan: #3498db;
@import "variables.darkly";
$primary: $blue;
$secondary: #444;
$success: $green;
$info: $cyan;
$warning: $yellow;
$danger: $red;
$light: $gray-800;
$dark: $gray-300;
$yiq-contrasted-threshold: 175;
$body-bg: $gray-900;
$body-color: $gray-300;
$theme-colors: (
"primary": $primary,
"secondary": $secondary,
"light": $light,
);
$link-color: $red;
$font-family-sans-serif: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol";
$font-size-base: 0.9375rem;
$h1-font-size: 3rem;
$h2-font-size: 2.5rem;
$h3-font-size: 2rem;
$text-muted: $gray-600;
$table-accent-bg: $gray-800;
$table-border-color: $gray-700;
$input-border-color: $body-bg;
$input-group-addon-color: $gray-500;
$input-group-addon-bg: $gray-700;
$custom-file-color: $gray-500;
$custom-file-border-color: $body-bg;
$dropdown-bg: $gray-900;
$dropdown-border-color: $gray-700;
$dropdown-divider-bg: $gray-700;
$dropdown-link-color: $white;
$dropdown-link-hover-color: $white;
$dropdown-link-hover-bg: $primary;
$nav-link-padding-x: 2rem;
$nav-link-disabled-color: $gray-500;
$nav-tabs-border-color: $gray-700;
$nav-tabs-link-hover-border-color: $nav-tabs-border-color $nav-tabs-border-color
transparent;
$nav-tabs-link-active-color: $white;
$nav-tabs-link-active-border-color: $nav-tabs-border-color
$nav-tabs-border-color transparent;
$navbar-padding-y: 1rem;
$navbar-dark-color: rgba($white, 0.6);
$navbar-dark-hover-color: $white;
$navbar-light-color: rgba($white, 0.6);
$navbar-light-hover-color: $white;
$navbar-light-active-color: $white;
$navbar-light-toggler-border-color: rgba($gray-900, 0.1);
$pagination-color: $white;
$pagination-bg: $success;
$pagination-border-width: 0;
$pagination-border-color: transparent;
$pagination-hover-color: $white;
$pagination-hover-bg: lighten($success, 10%);
$pagination-hover-border-color: transparent;
$pagination-active-bg: $pagination-hover-bg;
$pagination-active-border-color: transparent;
$pagination-disabled-color: $white;
$pagination-disabled-bg: darken($success, 15%);
$pagination-disabled-border-color: transparent;
$jumbotron-bg: $gray-800;
$card-cap-bg: $gray-700;
$card-bg: $gray-800;
$popover-bg: $gray-800;
$popover-header-bg: $gray-700;
$toast-background-color: $gray-700;
$toast-header-background-color: $gray-800;
$modal-content-bg: $gray-800;
$modal-content-border-color: $gray-700;
$modal-header-border-color: $gray-700;
$progress-bg: $gray-700;
$list-group-bg: $gray-800;
$list-group-border-color: $gray-700;
$list-group-hover-bg: $gray-700;
$breadcrumb-bg: $gray-700;
$close-color: $white;
$close-text-shadow: none;
$pre-color: inherit;
$mark-bg: #333;
$custom-select-bg: $secondary;
$custom-select-color: $white;
$input-bg: $secondary;
$input-color: $white;
$input-disabled-bg: darken($secondary, 10%);
$light: $gray-800;
$navbar-light-brand-color: $white;
$navbar-light-brand-hover-color: $navbar-light-brand-color;

View file

@ -1,35 +1,59 @@
// Colors
$white: #fff;
$gray-100: #f8f9fa;
$gray-200: #ebebeb;
$gray-300: #dee2e6;
$gray-400: #ced4da;
$gray-500: #adb5bd;
$gray-600: #888;
$gray-700: #444;
$gray-800: #303030;
$gray-900: #222;
$black: #000;
// Writing these maps is necessary for Bootstrap theming:
// https://getbootstrap.com/docs/4.6/getting-started/introduction/
$grays: (
"gray-200": $gray-200,
"gray-600": $gray-600,
"gray-700": $gray-700,
"gray-800": $gray-800,
"gray-900": $gray-900,
);
$blue: #375a7f;
$indigo: #6610f2;
$purple: #6f42c1;
$pink: #e83e8c;
$red: #e74c3c;
$orange: #fd7e14;
$yellow: #f39c12;
$green: #00bc8c;
$teal: #20c997;
$cyan: #3498db;
$primary: $blue;
// Writing these maps is necessary for Bootstrap theming:
// https://getbootstrap.com/docs/4.6/getting-started/introduction/
$colors: (
"blue": $blue,
"red": $red,
"yellow": $yellow,
"green": $green,
"cyan": $cyan,
);
$primary: $green;
$secondary: $gray-700;
$success: $green;
$info: $cyan;
$warning: $yellow;
$danger: $red;
$dark: $gray-300;
$yiq-contrasted-threshold: 175;
$body-bg: $gray-900;
// Writing these maps is necessary for Bootstrap theming:
// https://getbootstrap.com/docs/4.6/getting-started/introduction/
$theme-colors: (
"primary": $primary,
"secondary": $secondary,
"dark": $dark,
);
$body-color: $gray-300;
$body-bg: $gray-900;
$link-color: $success;
$mark-bg: #333;
$text-muted: $gray-600;
$yiq-contrasted-threshold: 175;
$font-family-sans-serif: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol";
@ -37,28 +61,10 @@ $font-size-base: 0.9375rem;
$h1-font-size: 3rem;
$h2-font-size: 2.5rem;
$h3-font-size: 2rem;
$text-muted: $gray-600;
$table-accent-bg: $gray-800;
$table-border-color: $gray-700;
$input-border-color: $body-bg;
$input-group-addon-color: $gray-500;
$input-group-addon-bg: $gray-700;
$custom-file-color: $gray-500;
$custom-file-border-color: $body-bg;
$dropdown-bg: $gray-900;
$dropdown-border-color: $gray-700;
$dropdown-divider-bg: $gray-700;
$dropdown-link-color: $white;
$dropdown-link-hover-color: $white;
$dropdown-link-hover-bg: $primary;
$nav-link-padding-x: 2rem;
$nav-link-disabled-color: $gray-500;
$nav-tabs-border-color: $gray-700;
$nav-tabs-link-hover-border-color: $nav-tabs-border-color $nav-tabs-border-color
transparent;
$nav-tabs-link-active-color: $white;
$nav-tabs-link-active-border-color: $nav-tabs-border-color
$nav-tabs-border-color transparent;
$card-cap-bg: $gray-700;
$card-bg: $gray-800;
$navbar-padding-y: 1rem;
$navbar-dark-color: rgba($white, 0.6);
$navbar-dark-hover-color: $white;
@ -66,6 +72,41 @@ $navbar-light-color: rgba($white, 0.6);
$navbar-light-hover-color: $white;
$navbar-light-active-color: $white;
$navbar-light-toggler-border-color: rgba($gray-900, 0.1);
$navbar-light-brand-color: $white;
$navbar-light-brand-hover-color: $navbar-light-brand-color;
$nav-link-padding-x: 2rem;
$nav-link-disabled-color: $gray-500;
$nav-tabs-border-color: $gray-700;
$nav-tabs-link-hover-border-color: $nav-tabs-border-color $nav-tabs-border-color
transparent;
$nav-tabs-link-active-color: $white;
$nav-tabs-link-active-border-color: $nav-tabs-border-color
$nav-tabs-border-color transparent;
$input-bg: $gray-700;
$input-color: $white;
$input-disabled-bg: darken($gray-700, 10%);
$input-border-color: $body-bg;
$input-group-addon-color: $gray-500;
$input-group-addon-bg: $gray-700;
$hr-border-color: rgba($body-color, 0.25);
$table-accent-bg: $gray-800;
$table-border-color: $gray-700;
$custom-file-color: $gray-500;
$custom-file-border-color: $body-bg;
$dropdown-bg: $gray-900;
$dropdown-border-color: $gray-700;
$dropdown-divider-bg: $gray-700;
$dropdown-link-color: $white;
$dropdown-link-hover-color: $white;
$dropdown-link-hover-bg: $primary;
$pagination-color: $white;
$pagination-bg: $success;
$pagination-border-width: 0;
@ -78,9 +119,8 @@ $pagination-active-border-color: transparent;
$pagination-disabled-color: $white;
$pagination-disabled-bg: darken($success, 15%);
$pagination-disabled-border-color: transparent;
$jumbotron-bg: $gray-800;
$card-cap-bg: $gray-700;
$card-bg: $gray-800;
$popover-bg: $gray-800;
$popover-header-bg: $gray-700;
$toast-background-color: $gray-700;
@ -96,12 +136,6 @@ $breadcrumb-bg: $gray-700;
$close-color: $white;
$close-text-shadow: none;
$pre-color: inherit;
$mark-bg: #333;
$custom-select-bg: $secondary;
$custom-select-bg: $gray-700;
$custom-select-color: $white;
$input-bg: $secondary;
$input-color: $white;
$input-disabled-bg: darken($secondary, 10%);
$light: $gray-800;
$navbar-light-brand-color: $white;
$navbar-light-brand-hover-color: $navbar-light-brand-color;

View file

@ -1,47 +1,9 @@
$white: #fff;
$gray-100: #f8f9fa;
$gray-200: #e9ecef;
$gray-300: #dee2e6;
$gray-400: #ced4da;
$gray-500: #adb5bd;
$gray-600: #6c757d;
$gray-700: #495057;
$gray-800: #343a40;
$gray-900: #212529;
$black: #000;
$blue: #007bff;
$indigo: #6610f2;
$white: #ffffff;
$orange: #f1641e;
$cyan: #02bdc2;
$green: #00c853;
$primary: #f1641e;
@import "variables.darkly";
$secondary: #c80000;
$info: $blue;
$body-color: $gray-700;
$link-color: $primary;
$red: #d8486a;
$border-radius: 0.5rem;
$border-radius-lg: 0.5rem;
$border-radius-sm: 1rem;
$font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Droid Sans",
"Segoe UI", "Helvetica", Arial, sans-serif;
$headings-color: $gray-700;
$input-btn-focus-color: rgba($primary, 0.75);
$form-feedback-valid-color: $info;
$navbar-light-color: $gray-600;
$black: #222222;
$navbar-dark-toggler-border-color: rgba($black, 0.1);
$navbar-light-active-color: $gray-900;
$card-color: $gray-700;
$card-cap-color: $gray-700;
$info: $blue;
$body-bg: #fff;
$success: $indigo;
$danger: darken($primary, 24%);
$navbar-light-hover-color: $gray-900;
$card-bg: $gray-100;
$border-color: $gray-700;
$mark-bg: rgb(255, 252, 239);
$font-weight-bold: 600;
$rounded-pill: 0.25rem;
$theme-colors: (
"secondary": $secondary,
"danger": $danger,
);

View file

@ -1,47 +1,80 @@
$white: #fff;
// Colors
$gray-100: #f8f9fa;
$gray-200: #e9ecef;
$gray-300: #dee2e6;
$gray-400: #ced4da;
$gray-500: #adb5bd;
$gray-600: #6c757d;
$gray-700: #495057;
$gray-800: #343a40;
$gray-900: #212529;
$black: #000;
$black: #222;
// Writing these maps is necessary for Bootstrap theming:
// https://getbootstrap.com/docs/4.6/getting-started/introduction/
$grays: (
"gray-200": $gray-200,
"gray-600": $gray-600,
"gray-700": $gray-700,
"gray-800": $gray-800,
"gray-900": $gray-900,
);
$blue: #007bff;
$indigo: #6610f2;
$white: #ffffff;
$red: #d8486a;
$orange: #f1641e;
$cyan: #02bdc2;
$green: #00c853;
$cyan: #02bdc2;
// Writing these maps is necessary for Bootstrap theming:
// https://getbootstrap.com/docs/4.6/getting-started/introduction/
$colors: (
"red": $red,
"orange": $orange,
"cyan": $cyan,
"green": $green,
);
$primary: $orange;
$secondary: $green;
$info: $cyan;
$success: $indigo;
$info: $blue;
$danger: darken($primary, 25%);
// Writing these maps is necessary for Bootstrap theming:
// https://getbootstrap.com/docs/4.6/getting-started/introduction/
$theme-colors: (
"primary": $primary,
"secondary": $secondary,
"success": $success,
"info": $info,
"danger": $danger,
);
$body-color: $gray-700;
$body-bg: #fff;
$link-color: $primary;
$red: #d8486a;
$border-color: $gray-700;
$mark-bg: rgb(255, 252, 239);
$headings-color: $gray-700;
$font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Droid Sans",
"Segoe UI", "Helvetica", Arial, sans-serif;
$font-weight-bold: 600;
$card-color: $gray-700;
$card-cap-color: $gray-700;
$card-bg: $gray-100;
$navbar-dark-toggler-border-color: rgba($black, 0.1);
$navbar-light-color: $gray-600;
$navbar-light-hover-color: $gray-900;
$navbar-light-active-color: $gray-900;
$form-feedback-valid-color: $info;
$input-btn-focus-color: rgba($primary, 0.75);
$border-radius: 0.5rem;
$border-radius-lg: 0.5rem;
$border-radius-sm: 1rem;
$font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Droid Sans",
"Segoe UI", "Helvetica", Arial, sans-serif;
$headings-color: $gray-700;
$input-btn-focus-color: rgba($primary, 0.75);
$form-feedback-valid-color: $info;
$navbar-light-color: $gray-600;
$black: #222222;
$navbar-dark-toggler-border-color: rgba($black, 0.1);
$navbar-light-active-color: $gray-900;
$card-color: $gray-700;
$card-cap-color: $gray-700;
$info: $blue;
$body-bg: #fff;
$success: $indigo;
$danger: darken($primary, 25%);
$navbar-light-hover-color: $gray-900;
$card-bg: $gray-100;
$border-color: $gray-700;
$mark-bg: rgb(255, 252, 239);
$font-weight-bold: 600;
$rounded-pill: 0.25rem;
$hr-border-color: rgba($body-color, 0.25);

View file

@ -450,7 +450,7 @@ hr {
margin-top: 1rem;
margin-bottom: 1rem;
border: 0;
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-top: 1px solid rgba(222, 226, 230, 0.25);
}
small,
@ -3245,7 +3245,7 @@ input[type="button"].btn-block {
.dropdown-item:focus {
color: #fff;
text-decoration: none;
background-color: #375a7f;
background-color: #00bc8c;
}
.dropdown-item.active,
.dropdown-item:active {

View file

@ -19,14 +19,13 @@
--white: #fff;
--gray: #888;
--gray-dark: #303030;
--primary: #375a7f;
--primary: #00bc8c;
--secondary: #444;
--success: #00bc8c;
--info: #3498db;
--warning: #f39c12;
--danger: #e74c3c;
--light: #303030;
--medium-light: var(--secondary);
--dark: #dee2e6;
--breakpoint-xs: 0;
--breakpoint-sm: 576px;
@ -451,7 +450,7 @@ hr {
margin-top: 1rem;
margin-bottom: 1rem;
border: 0;
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-top: 1px solid rgba(222, 226, 230, 0.25);
}
small,
@ -1625,21 +1624,21 @@ pre code {
.table-primary,
.table-primary > th,
.table-primary > td {
background-color: #c7d1db;
background-color: #b8ecdf;
}
.table-primary th,
.table-primary td,
.table-primary thead th,
.table-primary tbody + tbody {
border-color: #97a9bc;
border-color: #7adcc3;
}
.table-hover .table-primary:hover {
background-color: #b7c4d1;
background-color: #a4e7d6;
}
.table-hover .table-primary:hover > td,
.table-hover .table-primary:hover > th {
background-color: #b7c4d1;
background-color: #a4e7d6;
}
.table-secondary,
@ -1908,9 +1907,9 @@ pre code {
.form-control:focus {
color: #fff;
background-color: #444;
border-color: #739ac2;
border-color: #3dffcd;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(55, 90, 127, 0.25);
box-shadow: 0 0 0 0.2rem rgba(0, 188, 140, 0.25);
}
.form-control::placeholder {
color: #888;
@ -2408,7 +2407,7 @@ textarea.form-control.is-invalid {
.btn:focus,
.btn.focus {
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(55, 90, 127, 0.25);
box-shadow: 0 0 0 0.2rem rgba(0, 188, 140, 0.25);
}
.btn.disabled,
.btn:disabled {
@ -2424,38 +2423,38 @@ fieldset:disabled a.btn {
.btn-primary {
color: #fff;
background-color: #375a7f;
border-color: #375a7f;
background-color: #00bc8c;
border-color: #00bc8c;
}
.btn-primary:hover {
color: #fff;
background-color: #2b4764;
border-color: #28415b;
background-color: #009670;
border-color: #008966;
}
.btn-primary:focus,
.btn-primary.focus {
color: #fff;
background-color: #2b4764;
border-color: #28415b;
box-shadow: 0 0 0 0.2rem rgba(85, 115, 146, 0.5);
background-color: #009670;
border-color: #008966;
box-shadow: 0 0 0 0.2rem rgba(38, 198, 157, 0.5);
}
.btn-primary.disabled,
.btn-primary:disabled {
color: #fff;
background-color: #375a7f;
border-color: #375a7f;
background-color: #00bc8c;
border-color: #00bc8c;
}
.btn-primary:not(:disabled):not(.disabled):active,
.btn-primary:not(:disabled):not(.disabled).active,
.show > .btn-primary.dropdown-toggle {
color: #fff;
background-color: #28415b;
border-color: #243a53;
background-color: #008966;
border-color: #007c5d;
}
.btn-primary:not(:disabled):not(.disabled):active:focus,
.btn-primary:not(:disabled):not(.disabled).active:focus,
.show > .btn-primary.dropdown-toggle:focus {
box-shadow: 0 0 0 0.2rem rgba(85, 115, 146, 0.5);
box-shadow: 0 0 0 0.2rem rgba(38, 198, 157, 0.5);
}
.btn-secondary {
@ -2711,34 +2710,34 @@ fieldset:disabled a.btn {
}
.btn-outline-primary {
color: #375a7f;
border-color: #375a7f;
color: #00bc8c;
border-color: #00bc8c;
}
.btn-outline-primary:hover {
color: #fff;
background-color: #375a7f;
border-color: #375a7f;
background-color: #00bc8c;
border-color: #00bc8c;
}
.btn-outline-primary:focus,
.btn-outline-primary.focus {
box-shadow: 0 0 0 0.2rem rgba(55, 90, 127, 0.5);
box-shadow: 0 0 0 0.2rem rgba(0, 188, 140, 0.5);
}
.btn-outline-primary.disabled,
.btn-outline-primary:disabled {
color: #375a7f;
color: #00bc8c;
background-color: transparent;
}
.btn-outline-primary:not(:disabled):not(.disabled):active,
.btn-outline-primary:not(:disabled):not(.disabled).active,
.show > .btn-outline-primary.dropdown-toggle {
color: #fff;
background-color: #375a7f;
border-color: #375a7f;
background-color: #00bc8c;
border-color: #00bc8c;
}
.btn-outline-primary:not(:disabled):not(.disabled):active:focus,
.btn-outline-primary:not(:disabled):not(.disabled).active:focus,
.show > .btn-outline-primary.dropdown-toggle:focus {
box-shadow: 0 0 0 0.2rem rgba(55, 90, 127, 0.5);
box-shadow: 0 0 0 0.2rem rgba(0, 188, 140, 0.5);
}
.btn-outline-secondary {
@ -3246,13 +3245,13 @@ input[type="button"].btn-block {
.dropdown-item:focus {
color: #fff;
text-decoration: none;
background-color: #375a7f;
background-color: #00bc8c;
}
.dropdown-item.active,
.dropdown-item:active {
color: #fff;
text-decoration: none;
background-color: #375a7f;
background-color: #00bc8c;
}
.dropdown-item.disabled,
.dropdown-item:disabled {
@ -3617,19 +3616,19 @@ input[type="button"].btn-block {
}
.custom-control-input:checked ~ .custom-control-label::before {
color: #fff;
border-color: #375a7f;
background-color: #375a7f;
border-color: #00bc8c;
background-color: #00bc8c;
}
.custom-control-input:focus ~ .custom-control-label::before {
box-shadow: 0 0 0 0.2rem rgba(55, 90, 127, 0.25);
box-shadow: 0 0 0 0.2rem rgba(0, 188, 140, 0.25);
}
.custom-control-input:focus:not(:checked) ~ .custom-control-label::before {
border-color: #739ac2;
border-color: #3dffcd;
}
.custom-control-input:not(:disabled):active ~ .custom-control-label::before {
color: #fff;
background-color: #97b3d2;
border-color: #97b3d2;
background-color: #70ffda;
border-color: #70ffda;
}
.custom-control-input[disabled] ~ .custom-control-label,
.custom-control-input:disabled ~ .custom-control-label {
@ -3677,8 +3676,8 @@ input[type="button"].btn-block {
.custom-checkbox
.custom-control-input:indeterminate
~ .custom-control-label::before {
border-color: #375a7f;
background-color: #375a7f;
border-color: #00bc8c;
background-color: #00bc8c;
}
.custom-checkbox
.custom-control-input:indeterminate
@ -3688,12 +3687,12 @@ input[type="button"].btn-block {
.custom-checkbox
.custom-control-input:disabled:checked
~ .custom-control-label::before {
background-color: rgba(55, 90, 127, 0.5);
background-color: rgba(0, 188, 140, 0.5);
}
.custom-checkbox
.custom-control-input:disabled:indeterminate
~ .custom-control-label::before {
background-color: rgba(55, 90, 127, 0.5);
background-color: rgba(0, 188, 140, 0.5);
}
.custom-radio .custom-control-label::before {
@ -3705,7 +3704,7 @@ input[type="button"].btn-block {
.custom-radio
.custom-control-input:disabled:checked
~ .custom-control-label::before {
background-color: rgba(55, 90, 127, 0.5);
background-color: rgba(0, 188, 140, 0.5);
}
.custom-switch {
@ -3739,7 +3738,7 @@ input[type="button"].btn-block {
.custom-switch
.custom-control-input:disabled:checked
~ .custom-control-label::before {
background-color: rgba(55, 90, 127, 0.5);
background-color: rgba(0, 188, 140, 0.5);
}
.custom-select {
@ -3760,9 +3759,9 @@ input[type="button"].btn-block {
appearance: none;
}
.custom-select:focus {
border-color: #739ac2;
border-color: #3dffcd;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(55, 90, 127, 0.25);
box-shadow: 0 0 0 0.2rem rgba(0, 188, 140, 0.25);
}
.custom-select:focus::-ms-value {
color: #fff;
@ -3820,8 +3819,8 @@ input[type="button"].btn-block {
opacity: 0;
}
.custom-file-input:focus ~ .custom-file-label {
border-color: #739ac2;
box-shadow: 0 0 0 0.2rem rgba(55, 90, 127, 0.25);
border-color: #3dffcd;
box-shadow: 0 0 0 0.2rem rgba(0, 188, 140, 0.25);
}
.custom-file-input[disabled] ~ .custom-file-label,
.custom-file-input:disabled ~ .custom-file-label {
@ -3878,13 +3877,13 @@ input[type="button"].btn-block {
outline: 0;
}
.custom-range:focus::-webkit-slider-thumb {
box-shadow: 0 0 0 1px #222, 0 0 0 0.2rem rgba(55, 90, 127, 0.25);
box-shadow: 0 0 0 1px #222, 0 0 0 0.2rem rgba(0, 188, 140, 0.25);
}
.custom-range:focus::-moz-range-thumb {
box-shadow: 0 0 0 1px #222, 0 0 0 0.2rem rgba(55, 90, 127, 0.25);
box-shadow: 0 0 0 1px #222, 0 0 0 0.2rem rgba(0, 188, 140, 0.25);
}
.custom-range:focus::-ms-thumb {
box-shadow: 0 0 0 1px #222, 0 0 0 0.2rem rgba(55, 90, 127, 0.25);
box-shadow: 0 0 0 1px #222, 0 0 0 0.2rem rgba(0, 188, 140, 0.25);
}
.custom-range::-moz-focus-outer {
border: 0;
@ -3893,7 +3892,7 @@ input[type="button"].btn-block {
width: 1rem;
height: 1rem;
margin-top: -0.25rem;
background-color: #375a7f;
background-color: #00bc8c;
border: 0;
border-radius: 1rem;
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out,
@ -3906,7 +3905,7 @@ input[type="button"].btn-block {
}
}
.custom-range::-webkit-slider-thumb:active {
background-color: #97b3d2;
background-color: #70ffda;
}
.custom-range::-webkit-slider-runnable-track {
width: 100%;
@ -3920,7 +3919,7 @@ input[type="button"].btn-block {
.custom-range::-moz-range-thumb {
width: 1rem;
height: 1rem;
background-color: #375a7f;
background-color: #00bc8c;
border: 0;
border-radius: 1rem;
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out,
@ -3933,7 +3932,7 @@ input[type="button"].btn-block {
}
}
.custom-range::-moz-range-thumb:active {
background-color: #97b3d2;
background-color: #70ffda;
}
.custom-range::-moz-range-track {
width: 100%;
@ -3950,7 +3949,7 @@ input[type="button"].btn-block {
margin-top: 0;
margin-right: 0.2rem;
margin-left: 0.2rem;
background-color: #375a7f;
background-color: #00bc8c;
border: 0;
border-radius: 1rem;
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out,
@ -3963,7 +3962,7 @@ input[type="button"].btn-block {
}
}
.custom-range::-ms-thumb:active {
background-color: #97b3d2;
background-color: #70ffda;
}
.custom-range::-ms-track {
width: 100%;
@ -4075,7 +4074,7 @@ input[type="button"].btn-block {
.nav-pills .nav-link.active,
.nav-pills .show > .nav-link {
color: #fff;
background-color: #375a7f;
background-color: #00bc8c;
}
.nav-fill > .nav-link,
@ -4762,7 +4761,7 @@ input[type="button"].btn-block {
.page-link:focus {
z-index: 3;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(55, 90, 127, 0.25);
box-shadow: 0 0 0 0.2rem rgba(0, 188, 140, 0.25);
}
.page-item:first-child .page-link {
@ -4856,17 +4855,17 @@ a.badge:focus {
.badge-primary {
color: #fff;
background-color: #375a7f;
background-color: #00bc8c;
}
a.badge-primary:hover,
a.badge-primary:focus {
color: #fff;
background-color: #28415b;
background-color: #008966;
}
a.badge-primary:focus,
a.badge-primary.focus {
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(55, 90, 127, 0.5);
box-shadow: 0 0 0 0.2rem rgba(0, 188, 140, 0.5);
}
.badge-secondary {
@ -5021,15 +5020,15 @@ a.badge-dark.focus {
}
.alert-primary {
color: #1d2f42;
background-color: #d7dee5;
border-color: #c7d1db;
color: #006249;
background-color: #ccf2e8;
border-color: #b8ecdf;
}
.alert-primary hr {
border-top-color: #b7c4d1;
border-top-color: #a4e7d6;
}
.alert-primary .alert-link {
color: #0d161f;
color: #002f23;
}
.alert-secondary {
@ -5142,7 +5141,7 @@ a.badge-dark.focus {
color: #fff;
text-align: center;
white-space: nowrap;
background-color: #375a7f;
background-color: #00bc8c;
transition: width 0.6s ease;
}
@media (prefers-reduced-motion: reduce) {
@ -5232,8 +5231,8 @@ a.badge-dark.focus {
.list-group-item.active {
z-index: 2;
color: #fff;
background-color: #375a7f;
border-color: #375a7f;
background-color: #00bc8c;
border-color: #00bc8c;
}
.list-group-item + .list-group-item {
border-top-width: 0;
@ -5373,18 +5372,18 @@ a.badge-dark.focus {
}
.list-group-item-primary {
color: #1d2f42;
background-color: #c7d1db;
color: #006249;
background-color: #b8ecdf;
}
.list-group-item-primary.list-group-item-action:hover,
.list-group-item-primary.list-group-item-action:focus {
color: #1d2f42;
background-color: #b7c4d1;
color: #006249;
background-color: #a4e7d6;
}
.list-group-item-primary.list-group-item-action.active {
color: #fff;
background-color: #1d2f42;
border-color: #1d2f42;
background-color: #006249;
border-color: #006249;
}
.list-group-item-secondary {
@ -6290,14 +6289,14 @@ a.close.disabled {
}
.bg-primary {
background-color: #375a7f !important;
background-color: #00bc8c !important;
}
a.bg-primary:hover,
a.bg-primary:focus,
button.bg-primary:hover,
button.bg-primary:focus {
background-color: #28415b !important;
background-color: #008966 !important;
}
.bg-secondary {
@ -6426,7 +6425,7 @@ button.bg-dark:focus {
}
.border-primary {
border-color: #375a7f !important;
border-color: #00bc8c !important;
}
.border-secondary {
@ -9447,12 +9446,12 @@ button.bg-dark:focus {
}
.text-primary {
color: #375a7f !important;
color: #00bc8c !important;
}
a.text-primary:hover,
a.text-primary:focus {
color: #20344a !important;
color: #007053 !important;
}
.text-secondary {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,116 @@
import type { Request, Response } from "express";
import { StaticRouter, matchPath } from "inferno-router";
import { renderToString } from "inferno-server";
import IsomorphicCookie from "isomorphic-cookie";
import { GetSite, GetSiteResponse, LemmyHttp } from "lemmy-js-client";
import { App } from "../../shared/components/app/app";
import { getHttpBaseInternal } from "../../shared/env";
import {
InitialFetchRequest,
IsoDataOptionalSite,
RouteData,
} from "../../shared/interfaces";
import { routes } from "../../shared/routes";
import {
FailedRequestState,
wrapClient,
} from "../../shared/services/HttpService";
import { ErrorPageData, initializeSite, isAuthPath } from "../../shared/utils";
import { createSsrHtml } from "../utils/create-ssr-html";
import { getErrorPageData } from "../utils/get-error-page-data";
import { setForwardedHeaders } from "../utils/set-forwarded-headers";
export default async (req: Request, res: Response) => {
try {
const activeRoute = routes.find(route => matchPath(req.path, route));
let auth: string | undefined = IsomorphicCookie.load("jwt", req);
const getSiteForm: GetSite = { auth };
const headers = setForwardedHeaders(req.headers);
const client = wrapClient(new LemmyHttp(getHttpBaseInternal(), headers));
const { path, url, query } = req;
// Get site data first
// This bypasses errors, so that the client can hit the error on its own,
// in order to remove the jwt on the browser. Necessary for wrong jwts
let site: GetSiteResponse | undefined = undefined;
let routeData: RouteData = {};
let errorPageData: ErrorPageData | undefined = undefined;
let try_site = await client.getSite(getSiteForm);
if (try_site.state === "failed" && try_site.msg == "not_logged_in") {
console.error(
"Incorrect JWT token, skipping auth so frontend can remove jwt cookie"
);
getSiteForm.auth = undefined;
auth = undefined;
try_site = await client.getSite(getSiteForm);
}
if (!auth && isAuthPath(path)) {
return res.redirect("/login");
}
if (try_site.state === "success") {
site = try_site.data;
initializeSite(site);
if (path !== "/setup" && !site.site_view.local_site.site_setup) {
return res.redirect("/setup");
}
if (site && activeRoute?.fetchInitialData) {
const initialFetchReq: InitialFetchRequest = {
client,
auth,
path,
query,
site,
};
routeData = await activeRoute.fetchInitialData(initialFetchReq);
}
} else if (try_site.state === "failed") {
errorPageData = getErrorPageData(new Error(try_site.msg), site);
}
const error = Object.values(routeData).find(
res => res.state === "failed"
) as FailedRequestState | undefined;
// Redirect to the 404 if there's an API error
if (error) {
console.error(error.msg);
if (error.msg === "instance_is_private") {
return res.redirect(`/signup`);
} else {
errorPageData = getErrorPageData(new Error(error.msg), site);
}
}
const isoData: IsoDataOptionalSite = {
path,
site_res: site,
routeData,
errorPageData,
};
const wrapper = (
<StaticRouter location={url} context={isoData}>
<App />
</StaticRouter>
);
const root = renderToString(wrapper);
res.send(await createSsrHtml(root, isoData));
} catch (err) {
// If an error is caught here, the error page couldn't even be rendered
console.error(err);
res.statusCode = 500;
return res.send(
process.env.NODE_ENV === "development" ? err.message : "Server error"
);
}
};

View file

@ -0,0 +1,18 @@
import type { Response } from "express";
export default async ({ res }: { res: Response }) => {
res.setHeader("content-type", "text/plain; charset=utf-8");
res.send(`User-Agent: *
Disallow: /login
Disallow: /settings
Disallow: /create_community
Disallow: /create_post
Disallow: /create_private_message
Disallow: /inbox
Disallow: /setup
Disallow: /admin
Disallow: /password_change
Disallow: /search/
`);
};

View file

@ -0,0 +1,14 @@
import type { Response } from "express";
import path from "path";
export default async ({ res }: { res: Response }) => {
res
.setHeader("Content-Type", "application/javascript")
.sendFile(
path.resolve(
`./dist/service-worker${
process.env.NODE_ENV === "development" ? "-development" : ""
}.js`
)
);
};

View file

@ -0,0 +1,32 @@
import type { Request, Response } from "express";
import { existsSync } from "fs";
import path from "path";
const extraThemesFolder =
process.env["LEMMY_UI_EXTRA_THEMES_FOLDER"] || "./extra_themes";
export default async (req: Request, res: Response) => {
res.contentType("text/css");
const theme = req.params.name;
if (!theme.endsWith(".css")) {
res.statusCode = 400;
res.send("Theme must be a css file");
}
const customTheme = path.resolve(`./${extraThemesFolder}/${theme}`);
if (existsSync(customTheme)) {
res.sendFile(customTheme);
} else {
const internalTheme = path.resolve(`./dist/assets/css/themes/${theme}`);
// If the theme doesn't exist, just send litely
if (existsSync(internalTheme)) {
res.sendFile(internalTheme);
} else {
res.sendFile(path.resolve("./dist/assets/css/themes/litely.css"));
}
}
};

View file

@ -0,0 +1,6 @@
import type { Response } from "express";
import { buildThemeList } from "../utils/build-themes-list";
export default async ({ res }: { res: Response }) => {
res.type("json").send(JSON.stringify(await buildThemeList()));
};

View file

@ -1,463 +1,38 @@
import express from "express";
import { existsSync } from "fs";
import { readdir, readFile } from "fs/promises";
import { IncomingHttpHeaders } from "http";
import { Helmet } from "inferno-helmet";
import { matchPath, StaticRouter } from "inferno-router";
import { renderToString } from "inferno-server";
import IsomorphicCookie from "isomorphic-cookie";
import { GetSite, GetSiteResponse, LemmyHttp } from "lemmy-js-client";
import path from "path";
import process from "process";
import serialize from "serialize-javascript";
import sharp from "sharp";
import { App } from "../shared/components/app/app";
import { getHttpBaseExternal, getHttpBaseInternal } from "../shared/env";
import {
ILemmyConfig,
InitialFetchRequest,
IsoDataOptionalSite,
RouteData,
} from "../shared/interfaces";
import { routes } from "../shared/routes";
import { FailedRequestState, wrapClient } from "../shared/services/HttpService";
import {
ErrorPageData,
favIconPngUrl,
favIconUrl,
initializeSite,
isAuthPath,
} from "../shared/utils";
import CatchAllHandler from "./handlers/catch-all-handler";
import RobotsHandler from "./handlers/robots-handler";
import ServiceWorkerHandler from "./handlers/service-worker-handler";
import ThemeHandler from "./handlers/theme-handler";
import ThemesListHandler from "./handlers/themes-list-handler";
import setDefaultCsp from "./middleware/set-default-csp";
const server = express();
const [hostname, port] = process.env["LEMMY_UI_HOST"]
? process.env["LEMMY_UI_HOST"].split(":")
: ["0.0.0.0", "1234"];
const extraThemesFolder =
process.env["LEMMY_UI_EXTRA_THEMES_FOLDER"] || "./extra_themes";
if (!process.env["LEMMY_UI_DISABLE_CSP"] && !process.env["LEMMY_UI_DEBUG"]) {
server.use(function (_req, res, next) {
res.setHeader(
"Content-Security-Policy",
`default-src 'self'; manifest-src *; connect-src *; img-src * data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; form-action 'self'; base-uri 'self'; frame-src *; media-src *`
);
next();
});
}
const customHtmlHeader = process.env["LEMMY_UI_CUSTOM_HTML_HEADER"] || "";
server.use(express.json());
server.use(express.urlencoded({ extended: false }));
server.use("/static", express.static(path.resolve("./dist")));
const robotstxt = `User-Agent: *
Disallow: /login
Disallow: /settings
Disallow: /create_community
Disallow: /create_post
Disallow: /create_private_message
Disallow: /inbox
Disallow: /setup
Disallow: /admin
Disallow: /password_change
Disallow: /search/
`;
server.get("/service-worker.js", async (_req, res) => {
res.setHeader("Content-Type", "application/javascript");
res.sendFile(
path.resolve(
`./dist/service-worker${
process.env.NODE_ENV === "development" ? "-development" : ""
}.js`
)
);
});
server.get("/robots.txt", async (_req, res) => {
res.setHeader("content-type", "text/plain; charset=utf-8");
res.send(robotstxt);
});
server.get("/css/themes/:name", async (req, res) => {
res.contentType("text/css");
const theme = req.params.name;
if (!theme.endsWith(".css")) {
res.statusCode = 400;
res.send("Theme must be a css file");
}
const customTheme = path.resolve(`./${extraThemesFolder}/${theme}`);
if (existsSync(customTheme)) {
res.sendFile(customTheme);
} else {
const internalTheme = path.resolve(`./dist/assets/css/themes/${theme}`);
// If the theme doesn't exist, just send litely
if (existsSync(internalTheme)) {
res.sendFile(internalTheme);
} else {
res.sendFile(path.resolve("./dist/assets/css/themes/litely.css"));
}
}
});
async function buildThemeList(): Promise<string[]> {
const themes = ["darkly", "darkly-red", "litely", "litely-red"];
if (existsSync(extraThemesFolder)) {
const dirThemes = await readdir(extraThemesFolder);
const cssThemes = dirThemes
.filter(d => d.endsWith(".css"))
.map(d => d.replace(".css", ""));
themes.push(...cssThemes);
}
return themes;
if (!process.env["LEMMY_UI_DISABLE_CSP"] && !process.env["LEMMY_UI_DEBUG"]) {
server.use(setDefaultCsp);
}
server.get("/css/themelist", async (_req, res) => {
res.type("json");
res.send(JSON.stringify(await buildThemeList()));
});
// server.use(cookieParser());
server.get("/*", async (req, res) => {
try {
const activeRoute = routes.find(route => matchPath(req.path, route));
let auth: string | undefined = IsomorphicCookie.load("jwt", req);
const getSiteForm: GetSite = { auth };
const headers = setForwardedHeaders(req.headers);
const client = wrapClient(new LemmyHttp(getHttpBaseInternal(), headers));
const { path, url, query } = req;
// Get site data first
// This bypasses errors, so that the client can hit the error on its own,
// in order to remove the jwt on the browser. Necessary for wrong jwts
let site: GetSiteResponse | undefined = undefined;
let routeData: RouteData = {};
let errorPageData: ErrorPageData | undefined = undefined;
let try_site = await client.getSite(getSiteForm);
if (try_site.state === "failed" && try_site.msg == "not_logged_in") {
console.error(
"Incorrect JWT token, skipping auth so frontend can remove jwt cookie"
);
getSiteForm.auth = undefined;
auth = undefined;
try_site = await client.getSite(getSiteForm);
}
if (!auth && isAuthPath(path)) {
return res.redirect("/login");
}
if (try_site.state === "success") {
site = try_site.data;
initializeSite(site);
if (path !== "/setup" && !site.site_view.local_site.site_setup) {
return res.redirect("/setup");
}
if (site && activeRoute?.fetchInitialData) {
const initialFetchReq: InitialFetchRequest = {
client,
auth,
path,
query,
site,
};
routeData = await activeRoute.fetchInitialData(initialFetchReq);
}
} else if (try_site.state === "failed") {
errorPageData = getErrorPageData(new Error(try_site.msg), site);
}
const error = Object.values(routeData).find(
res => res.state === "failed"
) as FailedRequestState | undefined;
// Redirect to the 404 if there's an API error
if (error) {
console.error(error.msg);
if (error.msg === "instance_is_private") {
return res.redirect(`/signup`);
} else {
errorPageData = getErrorPageData(new Error(error.msg), site);
}
}
const isoData: IsoDataOptionalSite = {
path,
site_res: site,
routeData,
errorPageData,
};
const wrapper = (
<StaticRouter location={url} context={isoData}>
<App />
</StaticRouter>
);
const root = renderToString(wrapper);
res.send(await createSsrHtml(root, isoData));
} catch (err) {
// If an error is caught here, the error page couldn't even be rendered
console.error(err);
res.statusCode = 500;
return res.send(
process.env.NODE_ENV === "development" ? err.message : "Server error"
);
}
});
server.get("/robots.txt", RobotsHandler);
server.get("/service-worker.js", ServiceWorkerHandler);
server.get("/css/themes/:name", ThemeHandler);
server.get("/css/themelist", ThemesListHandler);
server.get("/*", CatchAllHandler);
server.listen(Number(port), hostname, () => {
console.log(`http://${hostname}:${port}`);
});
function setForwardedHeaders(headers: IncomingHttpHeaders): {
[key: string]: string;
} {
const out: { [key: string]: string } = {};
if (headers.host) {
out.host = headers.host;
}
const realIp = headers["x-real-ip"];
if (realIp) {
out["x-real-ip"] = realIp as string;
}
const forwardedFor = headers["x-forwarded-for"];
if (forwardedFor) {
out["x-forwarded-for"] = forwardedFor as string;
}
return out;
}
process.on("SIGINT", () => {
console.info("Interrupted");
process.exit(0);
});
const iconSizes = [72, 96, 144, 192, 512];
const defaultLogoPathDirectory = path.join(
process.cwd(),
"dist",
"assets",
"icons"
);
export async function generateManifestBase64({
my_user,
site_view: {
site,
local_site: { community_creation_admin_only },
},
}: GetSiteResponse) {
const url = getHttpBaseExternal();
const icon = site.icon ? await fetchIconPng(site.icon) : null;
const manifest = {
name: site.name,
description: site.description ?? "A link aggregator for the fediverse",
start_url: url,
scope: url,
display: "standalone",
id: "/",
background_color: "#222222",
theme_color: "#222222",
icons: await Promise.all(
iconSizes.map(async size => {
let src = await readFile(
path.join(defaultLogoPathDirectory, `icon-${size}x${size}.png`)
).then(buf => buf.toString("base64"));
if (icon) {
src = await sharp(icon)
.resize(size, size)
.png()
.toBuffer()
.then(buf => buf.toString("base64"));
}
return {
sizes: `${size}x${size}`,
type: "image/png",
src: `data:image/png;base64,${src}`,
purpose: "any maskable",
};
})
),
shortcuts: [
{
name: "Search",
short_name: "Search",
description: "Perform a search.",
url: "/search",
},
{
name: "Communities",
url: "/communities",
short_name: "Communities",
description: "Browse communities",
},
]
.concat(
my_user
? [
{
name: "Create Post",
url: "/create_post",
short_name: "Create Post",
description: "Create a post.",
},
]
: []
)
.concat(
my_user?.local_user_view.person.admin || !community_creation_admin_only
? [
{
name: "Create Community",
url: "/create_community",
short_name: "Create Community",
description: "Create a community",
},
]
: []
),
related_applications: [
{
platform: "f-droid",
url: "https://f-droid.org/packages/com.jerboa/",
id: "com.jerboa",
},
],
};
return Buffer.from(JSON.stringify(manifest)).toString("base64");
}
async function fetchIconPng(iconUrl: string) {
return await fetch(iconUrl)
.then(res => res.blob())
.then(blob => blob.arrayBuffer());
}
function getErrorPageData(error: Error, site?: GetSiteResponse) {
const errorPageData: ErrorPageData = {};
if (site) {
errorPageData.error = error.message;
}
const adminMatrixIds = site?.admins
.map(({ person: { matrix_user_id } }) => matrix_user_id)
.filter(id => id) as string[] | undefined;
if (adminMatrixIds && adminMatrixIds.length > 0) {
errorPageData.adminMatrixIds = adminMatrixIds;
}
return errorPageData;
}
async function createSsrHtml(root: string, isoData: IsoDataOptionalSite) {
const site = isoData.site_res;
const appleTouchIcon = site?.site_view.site.icon
? `data:image/png;base64,${sharp(
await fetchIconPng(site.site_view.site.icon)
)
.resize(180, 180)
.extend({
bottom: 20,
top: 20,
left: 20,
right: 20,
background: "#222222",
})
.png()
.toBuffer()
.then(buf => buf.toString("base64"))}`
: favIconPngUrl;
const erudaStr =
process.env["LEMMY_UI_DEBUG"] === "true"
? renderToString(
<>
<script src="//cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script>
</>
)
: "";
const helmet = Helmet.renderStatic();
const config: ILemmyConfig = { wsHost: process.env.LEMMY_UI_LEMMY_WS_HOST };
return `
<!DOCTYPE html>
<html ${helmet.htmlAttributes.toString()}>
<head>
<script>window.isoData = ${serialize(isoData)}</script>
<script>window.lemmyConfig = ${serialize(config)}</script>
<!-- A remote debugging utility for mobile -->
${erudaStr}
<!-- Custom injected script -->
${customHtmlHeader}
${helmet.title.toString()}
${helmet.meta.toString()}
<!-- Required meta tags -->
<meta name="Description" content="Lemmy">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no">
<link
id="favicon"
rel="shortcut icon"
type="image/x-icon"
href=${site?.site_view.site.icon ?? favIconUrl}
/>
<!-- Web app manifest -->
${
site &&
`<link
rel="manifest"
href=${`data:application/manifest+json;base64,${await generateManifestBase64(
site
)}`}
/>`
}
<link rel="apple-touch-icon" href=${appleTouchIcon} />
<link rel="apple-touch-startup-image" href=${appleTouchIcon} />
<!-- Styles -->
<link rel="stylesheet" type="text/css" href="/static/styles/styles.css" />
<!-- Current theme and more -->
${helmet.link.toString()}
</head>
<body ${helmet.bodyAttributes.toString()}>
<noscript>
<div class="alert alert-danger rounded-0" role="alert">
<b>Javascript is disabled. Actions will not work.</b>
</div>
</noscript>
<div id='root'>${root}</div>
<script defer src='/static/js/client.js'></script>
</body>
</html>
`;
}

View file

@ -0,0 +1,10 @@
import type { NextFunction, Response } from "express";
export default function ({ res, next }: { res: Response; next: NextFunction }) {
res.setHeader(
"Content-Security-Policy",
`default-src 'self'; manifest-src *; connect-src *; img-src * data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; form-action 'self'; base-uri 'self'; frame-src *; media-src *`
);
next();
}

View file

@ -0,0 +1,18 @@
import { existsSync } from "fs";
import { readdir } from "fs/promises";
const extraThemesFolder =
process.env["LEMMY_UI_EXTRA_THEMES_FOLDER"] || "./extra_themes";
const themes = ["darkly", "darkly-red", "litely", "litely-red"];
export async function buildThemeList(): Promise<string[]> {
if (existsSync(extraThemesFolder)) {
const dirThemes = await readdir(extraThemesFolder);
const cssThemes = dirThemes
.filter(d => d.endsWith(".css"))
.map(d => d.replace(".css", ""));
themes.push(...cssThemes);
}
return themes;
}

View file

@ -0,0 +1,109 @@
import { Helmet } from "inferno-helmet";
import { renderToString } from "inferno-server";
import serialize from "serialize-javascript";
import sharp from "sharp";
import { ILemmyConfig, IsoDataOptionalSite } from "../../shared/interfaces";
import { favIconPngUrl, favIconUrl } from "../../shared/utils";
import { fetchIconPng } from "./fetch-icon-png";
import { generateManifestBase64 } from "./generate-manifest-base64";
const customHtmlHeader = process.env["LEMMY_UI_CUSTOM_HTML_HEADER"] || "";
export async function createSsrHtml(
root: string,
isoData: IsoDataOptionalSite
) {
const site = isoData.site_res;
const appleTouchIcon = site?.site_view.site.icon
? `data:image/png;base64,${sharp(
await fetchIconPng(site.site_view.site.icon)
)
.resize(180, 180)
.extend({
bottom: 20,
top: 20,
left: 20,
right: 20,
background: "#222222",
})
.png()
.toBuffer()
.then(buf => buf.toString("base64"))}`
: favIconPngUrl;
const erudaStr =
process.env["LEMMY_UI_DEBUG"] === "true"
? renderToString(
<>
<script src="//cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script>
</>
)
: "";
const helmet = Helmet.renderStatic();
const config: ILemmyConfig = { wsHost: process.env.LEMMY_UI_LEMMY_WS_HOST };
return `
<!DOCTYPE html>
<html ${helmet.htmlAttributes.toString()}>
<head>
<script>window.isoData = ${serialize(isoData)}</script>
<script>window.lemmyConfig = ${serialize(config)}</script>
<!-- A remote debugging utility for mobile -->
${erudaStr}
<!-- Custom injected script -->
${customHtmlHeader}
${helmet.title.toString()}
${helmet.meta.toString()}
<!-- Required meta tags -->
<meta name="Description" content="Lemmy">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no">
<link
id="favicon"
rel="shortcut icon"
type="image/x-icon"
href=${site?.site_view.site.icon ?? favIconUrl}
/>
<!-- Web app manifest -->
${
site &&
`<link
rel="manifest"
href=${`data:application/manifest+json;base64,${await generateManifestBase64(
site
)}`}
/>`
}
<link rel="apple-touch-icon" href=${appleTouchIcon} />
<link rel="apple-touch-startup-image" href=${appleTouchIcon} />
<!-- Styles -->
<link rel="stylesheet" type="text/css" href="/static/styles/styles.css" />
<!-- Current theme and more -->
${helmet.link.toString()}
</head>
<body ${helmet.bodyAttributes.toString()}>
<noscript>
<div class="alert alert-danger rounded-0" role="alert">
<b>Javascript is disabled. Actions will not work.</b>
</div>
</noscript>
<div id='root'>${root}</div>
<script defer src='/static/js/client.js'></script>
</body>
</html>
`;
}

View file

@ -0,0 +1,5 @@
export async function fetchIconPng(iconUrl: string) {
return await fetch(iconUrl)
.then(res => res.blob())
.then(blob => blob.arrayBuffer());
}

View file

@ -0,0 +1,107 @@
import { readFile } from "fs/promises";
import { GetSiteResponse } from "lemmy-js-client";
import path from "path";
import sharp from "sharp";
import { getHttpBaseExternal } from "../../shared/env";
import { fetchIconPng } from "./fetch-icon-png";
const iconSizes = [72, 96, 144, 192, 512];
const defaultLogoPathDirectory = path.join(
process.cwd(),
"dist",
"assets",
"icons"
);
export async function generateManifestBase64({
my_user,
site_view: {
site,
local_site: { community_creation_admin_only },
},
}: GetSiteResponse) {
const url = getHttpBaseExternal();
const icon = site.icon ? await fetchIconPng(site.icon) : null;
const manifest = {
name: site.name,
description: site.description ?? "A link aggregator for the fediverse",
start_url: url,
scope: url,
display: "standalone",
id: "/",
background_color: "#222222",
theme_color: "#222222",
icons: await Promise.all(
iconSizes.map(async size => {
let src = await readFile(
path.join(defaultLogoPathDirectory, `icon-${size}x${size}.png`)
).then(buf => buf.toString("base64"));
if (icon) {
src = await sharp(icon)
.resize(size, size)
.png()
.toBuffer()
.then(buf => buf.toString("base64"));
}
return {
sizes: `${size}x${size}`,
type: "image/png",
src: `data:image/png;base64,${src}`,
purpose: "any maskable",
};
})
),
shortcuts: [
{
name: "Search",
short_name: "Search",
description: "Perform a search.",
url: "/search",
},
{
name: "Communities",
url: "/communities",
short_name: "Communities",
description: "Browse communities",
},
]
.concat(
my_user
? [
{
name: "Create Post",
url: "/create_post",
short_name: "Create Post",
description: "Create a post.",
},
]
: []
)
.concat(
my_user?.local_user_view.person.admin || !community_creation_admin_only
? [
{
name: "Create Community",
url: "/create_community",
short_name: "Create Community",
description: "Create a community",
},
]
: []
),
related_applications: [
{
platform: "f-droid",
url: "https://f-droid.org/packages/com.jerboa/",
id: "com.jerboa",
},
],
};
return Buffer.from(JSON.stringify(manifest)).toString("base64");
}

View file

@ -0,0 +1,20 @@
import { GetSiteResponse } from "lemmy-js-client";
import { ErrorPageData } from "../../shared/utils";
export function getErrorPageData(error: Error, site?: GetSiteResponse) {
const errorPageData: ErrorPageData = {};
if (site) {
errorPageData.error = error.message;
}
const adminMatrixIds = site?.admins
.map(({ person: { matrix_user_id } }) => matrix_user_id)
.filter(id => id) as string[] | undefined;
if (adminMatrixIds && adminMatrixIds.length > 0) {
errorPageData.adminMatrixIds = adminMatrixIds;
}
return errorPageData;
}

View file

@ -0,0 +1,25 @@
import { IncomingHttpHeaders } from "http";
export function setForwardedHeaders(headers: IncomingHttpHeaders): {
[key: string]: string;
} {
const out: { [key: string]: string } = {};
if (headers.host) {
out.host = headers.host;
}
const realIp = headers["x-real-ip"];
if (realIp) {
out["x-real-ip"] = realIp as string;
}
const forwardedFor = headers["x-forwarded-for"];
if (forwardedFor) {
out["x-forwarded-for"] = forwardedFor as string;
}
return out;
}

View file

@ -15,17 +15,19 @@ export class BannerIconHeader extends Component<BannerIconHeaderProps, any> {
const banner = this.props.banner;
const icon = this.props.icon;
return (
<div className="position-relative mb-2">
{banner && <PictrsImage src={banner} banner alt="" />}
{icon && (
<PictrsImage
src={icon}
iconOverlay
pushup={!!this.props.banner}
alt=""
/>
)}
</div>
(banner || icon) && (
<div className="position-relative mb-2">
{banner && <PictrsImage src={banner} banner alt="" />}
{icon && (
<PictrsImage
src={icon}
iconOverlay
pushup={!!this.props.banner}
alt=""
/>
)}
</div>
)
);
}
}

View file

@ -443,16 +443,17 @@ export class Home extends Component<any, HomeState> {
admins={admins}
counts={counts}
showLocal={showLocal(this.isoData)}
isMobile={true}
/>
)}
{showTrendingMobile && (
<div className="col-12 card border-secondary mb-3">
<div className="card-body">{this.trendingCommunities(true)}</div>
<div className="card border-secondary mb-3">
{this.trendingCommunities()}
</div>
)}
{showSubscribedMobile && (
<div className="col-12 card border-secondary mb-3">
<div className="card-body">{this.subscribedCommunities}</div>
<div className="card border-secondary mb-3">
{this.subscribedCommunities(true)}
</div>
)}
</div>
@ -471,19 +472,7 @@ export class Home extends Component<any, HomeState> {
return (
<div id="sidebarContainer">
<section id="sidebarMain" className="card border-secondary mb-3">
<div className="card-body">
{this.trendingCommunities()}
{canCreateCommunity(this.state.siteRes) && (
<LinkButton
path="/create_community"
translationKey="create_a_community"
/>
)}
<LinkButton
path="/communities"
translationKey="explore_communities"
/>
</div>
{this.trendingCommunities()}
</section>
<SiteSidebar
site={site}
@ -492,18 +481,20 @@ export class Home extends Component<any, HomeState> {
showLocal={showLocal(this.isoData)}
/>
{this.hasFollows && (
<section
id="sidebarSubscribed"
className="card border-secondary mb-3"
>
<div className="card-body">{this.subscribedCommunities}</div>
</section>
<div className="accordion">
<section
id="sidebarSubscribed"
className="card border-secondary mb-3"
>
{this.subscribedCommunities(false)}
</section>
</div>
)}
</div>
);
}
trendingCommunities(isMobile = false) {
trendingCommunities() {
switch (this.state.trendingCommunitiesRes?.state) {
case "loading":
return (
@ -514,68 +505,103 @@ export class Home extends Component<any, HomeState> {
case "success": {
const trending = this.state.trendingCommunitiesRes.data.communities;
return (
<div className={!isMobile ? "mb-2" : ""}>
<h5>
<T i18nKey="trending_communities">
#
<Link className="text-body" to="/communities">
<>
<header className="card-header d-flex align-items-center">
<h5 className="mb-0">
<T i18nKey="trending_communities">
#
</Link>
</T>
</h5>
<ul className="list-inline mb-0">
{trending.map(cv => (
<li
key={cv.community.id}
className="list-inline-item d-inline-block"
>
<CommunityLink community={cv.community} />
</li>
))}
</ul>
</div>
<Link className="text-body" to="/communities">
#
</Link>
</T>
</h5>
</header>
<div className="card-body">
{trending.length > 0 && (
<ul className="list-inline">
{trending.map(cv => (
<li key={cv.community.id} className="list-inline-item">
<CommunityLink community={cv.community} />
</li>
))}
</ul>
)}
{canCreateCommunity(this.state.siteRes) && (
<LinkButton
path="/create_community"
translationKey="create_a_community"
/>
)}
<LinkButton
path="/communities"
translationKey="explore_communities"
/>
</div>
</>
);
}
}
}
get subscribedCommunities() {
subscribedCommunities(isMobile = false) {
const { subscribedCollapsed } = this.state;
return (
<div>
<h5>
<T class="d-inline" i18nKey="subscribed_to_communities">
#
<Link className="text-body" to="/communities">
<>
<header
className="card-header d-flex align-items-center"
id="sidebarSubscribedHeader"
>
<h5 className="mb-0 d-inline">
<T class="d-inline" i18nKey="subscribed_to_communities">
#
</Link>
</T>
<button
className="btn btn-sm text-muted"
onClick={linkEvent(this, this.handleCollapseSubscribe)}
aria-label={i18n.t("collapse")}
data-tippy-content={i18n.t("collapse")}
>
<Icon
icon={`${subscribedCollapsed ? "plus" : "minus"}-square`}
classes="icon-inline"
/>
</button>
</h5>
{!subscribedCollapsed && (
<ul className="list-inline mb-0">
{UserService.Instance.myUserInfo?.follows.map(cfv => (
<li
key={cfv.community.id}
className="list-inline-item d-inline-block"
>
<CommunityLink community={cfv.community} />
</li>
))}
</ul>
)}
</div>
<Link className="text-body" to="/communities">
#
</Link>
</T>
</h5>
{!isMobile && (
<button
type="button"
className="btn btn-sm text-muted"
onClick={linkEvent(this, this.handleCollapseSubscribe)}
aria-label={
subscribedCollapsed ? i18n.t("expand") : i18n.t("collapse")
}
data-tippy-content={
subscribedCollapsed ? i18n.t("expand") : i18n.t("collapse")
}
data-bs-toggle="collapse"
data-bs-target="#sidebarSubscribedBody"
aria-expanded="true"
aria-controls="sidebarSubscribedBody"
>
<Icon
icon={`${subscribedCollapsed ? "plus" : "minus"}-square`}
classes="icon-inline"
/>
</button>
)}
</header>
<div
id="sidebarSubscribedBody"
className="collapse show"
aria-labelledby="sidebarSubscribedHeader"
>
<div className="card-body">
<ul className="list-inline mb-0">
{UserService.Instance.myUserInfo?.follows.map(cfv => (
<li
key={cfv.community.id}
className="list-inline-item d-inline-block"
>
<CommunityLink community={cfv.community} />
</li>
))}
</ul>
</div>
</div>
</>
);
}

View file

@ -298,27 +298,22 @@ export class Signup extends Component<any, State> {
</>
)}
{this.renderCaptcha()}
{siteView.local_site.enable_nsfw && (
<div className="form-group row">
<div className="col-sm-10">
<div className="form-check">
<input
className="form-check-input"
id="register-show-nsfw"
type="checkbox"
checked={this.state.form.show_nsfw}
onChange={linkEvent(this, this.handleRegisterShowNsfwChange)}
/>
<label
className="form-check-label"
htmlFor="register-show-nsfw"
>
{i18n.t("show_nsfw")}
</label>
</div>
<div className="form-group row">
<div className="col-sm-10">
<div className="form-check">
<input
className="form-check-input"
id="register-show-nsfw"
type="checkbox"
checked={this.state.form.show_nsfw}
onChange={linkEvent(this, this.handleRegisterShowNsfwChange)}
/>
<label className="form-check-label" htmlFor="register-show-nsfw">
{i18n.t("show_nsfw")}
</label>
</div>
</div>
)}
</div>
<input
tabIndex={-1}
autoComplete="false"

View file

@ -12,6 +12,7 @@ interface SiteSidebarProps {
showLocal: boolean;
counts?: SiteAggregates;
admins?: PersonView[];
isMobile?: boolean;
}
interface SiteSidebarState {
@ -29,39 +30,58 @@ export class SiteSidebar extends Component<SiteSidebarProps, SiteSidebarState> {
render() {
return (
<section id="sidebarInfo" className="card border-secondary mb-3">
<div className="card-body">
<div>
<div className="mb-2">{this.siteName()}</div>
<div className="accordion">
<section id="sidebarInfo" className="card border-secondary mb-3">
<header
className="card-header d-flex align-items-center"
id="sidebarInfoHeader"
>
{this.siteName()}
{!this.state.collapsed && (
<>
<BannerIconHeader banner={this.props.site.banner} />
{this.siteInfo()}
</>
<BannerIconHeader banner={this.props.site.banner} />
)}
</header>
<div
id="sidebarInfoBody"
className="collapse show"
aria-labelledby="sidebarInfoHeader"
>
<div className="card-body">{this.siteInfo()}</div>
</div>
</div>
</section>
</section>
</div>
);
}
siteName() {
return (
<h5 className="mb-0 d-inline">
{this.props.site.name}
<button
className="btn btn-sm text-muted"
onClick={linkEvent(this, this.handleCollapseSidebar)}
aria-label={i18n.t("collapse")}
data-tippy-content={i18n.t("collapse")}
>
{this.state.collapsed ? (
<Icon icon="plus-square" classes="icon-inline" />
) : (
<Icon icon="minus-square" classes="icon-inline" />
)}
</button>
</h5>
<>
<h5 className="mb-0 d-inline">{this.props.site.name}</h5>
{!this.props.isMobile && (
<button
type="button"
className="btn btn-sm"
onClick={linkEvent(this, this.handleCollapseSidebar)}
aria-label={
this.state.collapsed ? i18n.t("expand") : i18n.t("collapse")
}
data-tippy-content={
this.state.collapsed ? i18n.t("expand") : i18n.t("collapse")
}
data-bs-toggle="collapse"
data-bs-target="#sidebarInfoBody"
aria-expanded="true"
aria-controls="sidebarInfoBody"
>
{this.state.collapsed ? (
<Icon icon="plus-square" classes="icon-inline" />
) : (
<Icon icon="minus-square" classes="icon-inline" />
)}
</button>
)}
</>
);
}

View file

@ -1,3 +1,4 @@
import classNames from "classnames";
import { Component } from "inferno";
import { Link } from "inferno-router";
import { Person } from "lemmy-js-client";
@ -48,7 +49,10 @@ export class PersonListing extends Component<PersonListingProps, any> {
{!this.props.realLink ? (
<Link
title={apubName}
className={this.props.muted ? "text-muted" : "text-info"}
className={classNames("d-inline-flex align-items-baseline", {
"text-muted": this.props.muted,
"text-info": !this.props.muted,
})}
to={link}
>
{this.avatarAndName(displayName)}
@ -56,7 +60,9 @@ export class PersonListing extends Component<PersonListingProps, any> {
) : (
<a
title={apubName}
className={this.props.muted ? "text-muted" : "text-info"}
className={`d-inline-flex align-items-baseline ${
this.props.muted ? "text-muted" : "text-info"
}`}
href={link}
rel={relTags}
>

View file

@ -20,7 +20,6 @@ import {
communityToChoice,
elementUrl,
emDash,
enableNsfw,
fetchCommunities,
fetchThemeList,
fetchUsers,
@ -642,22 +641,20 @@ export class Settings extends Component<any, SettingsState> {
/>
</div>
</form>
{enableNsfw(this.state.siteRes) && (
<div className="form-group">
<div className="form-check">
<input
className="form-check-input"
id="user-show-nsfw"
type="checkbox"
checked={this.state.saveUserSettingsForm.show_nsfw}
onChange={linkEvent(this, this.handleShowNsfwChange)}
/>
<label className="form-check-label" htmlFor="user-show-nsfw">
{i18n.t("show_nsfw")}
</label>
</div>
<div className="form-group">
<div className="form-check">
<input
className="form-check-input"
id="user-show-nsfw"
type="checkbox"
checked={this.state.saveUserSettingsForm.show_nsfw}
onChange={linkEvent(this, this.handleShowNsfwChange)}
/>
<label className="form-check-label" htmlFor="user-show-nsfw">
{i18n.t("show_nsfw")}
</label>
</div>
)}
</div>
<div className="form-group">
<div className="form-check">
<input

View file

@ -201,7 +201,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
const post = this.postView.post;
return (
<div className="post-listing">
<div className="post-listing mt-2">
{!this.state.showEdit ? (
<>
{this.listing()}
@ -386,10 +386,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</span>
)}
{this.props.showCommunity && (
<span>
<span className="mx-1"> {i18n.t("to")} </span>
<CommunityLink community={post_view.community} />
</span>
<>
{" "}
{i18n.t("to")} <CommunityLink community={post_view.community} />
</>
)}
</li>
{post_view.post.language_id !== 0 && (
@ -497,7 +497,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
const post = this.postView.post;
return (
<Link
className={`d-inline-block ${
className={`d-inline ${
!post.featured_community && !post.featured_local
? "text-body"
: "text-primary"
@ -505,8 +505,8 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
to={`/post/${post.id}`}
title={i18n.t("comments")}
>
<div
className="d-inline-block"
<span
className="d-inline"
dangerouslySetInnerHTML={mdToHtmlInline(post.name)}
/>
</Link>
@ -519,88 +519,78 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
return (
<div className="post-title overflow-hidden">
<h5>
{url ? (
this.props.showBody ? (
<a
className={`d-inline-block ${
!post.featured_community && !post.featured_local
? "text-body"
: "text-primary"
}`}
href={url}
title={url}
rel={relTags}
>
<div
className="d-inline-block"
dangerouslySetInnerHTML={mdToHtmlInline(post.name)}
/>
</a>
) : (
this.postLink
)
<h5 className="d-inline">
{url && this.props.showBody ? (
<a
className={
!post.featured_community && !post.featured_local
? "text-body"
: "text-primary"
}
href={url}
title={url}
rel={relTags}
dangerouslySetInnerHTML={mdToHtmlInline(post.name)}
></a>
) : (
this.postLink
)}
{(url && isImage(url)) ||
(post.thumbnail_url && (
<button
className="btn btn-link text-monospace text-muted small d-inline-block"
data-tippy-content={i18n.t("expand_here")}
onClick={linkEvent(this, this.handleImageExpandClick)}
>
<Icon
icon={
!this.state.imageExpanded ? "plus-square" : "minus-square"
}
classes="icon-inline"
/>
</button>
))}
{post.removed && (
<small className="ml-2 text-muted font-italic">
{i18n.t("removed")}
</small>
)}
{post.deleted && (
<small
className="unselectable pointer ml-2 text-muted font-italic"
data-tippy-content={i18n.t("deleted")}
>
<Icon icon="trash" classes="icon-inline text-danger" />
</small>
)}
{post.locked && (
<small
className="unselectable pointer ml-2 text-muted font-italic"
data-tippy-content={i18n.t("locked")}
>
<Icon icon="lock" classes="icon-inline text-danger" />
</small>
)}
{post.featured_community && (
<small
className="unselectable pointer ml-2 text-muted font-italic"
data-tippy-content={i18n.t("featured")}
>
<Icon icon="pin" classes="icon-inline text-primary" />
</small>
)}
{post.featured_local && (
<small
className="unselectable pointer ml-2 text-muted font-italic"
data-tippy-content={i18n.t("featured")}
>
<Icon icon="pin" classes="icon-inline text-secondary" />
</small>
)}
{post.nsfw && (
<small className="ml-2 text-muted font-italic">
{i18n.t("nsfw")}
</small>
)}
</h5>
{(url && isImage(url)) ||
(post.thumbnail_url && (
<button
className="btn btn-link text-monospace text-muted small d-inline-block"
data-tippy-content={i18n.t("expand_here")}
onClick={linkEvent(this, this.handleImageExpandClick)}
>
<Icon
icon={
!this.state.imageExpanded ? "plus-square" : "minus-square"
}
classes="icon-inline"
/>
</button>
))}
{post.removed && (
<small className="ml-2 badge text-bg-secondary">
{i18n.t("removed")}
</small>
)}
{post.deleted && (
<small
className="unselectable pointer ml-2 text-muted font-italic"
data-tippy-content={i18n.t("deleted")}
>
<Icon icon="trash" classes="icon-inline text-danger" />
</small>
)}
{post.locked && (
<small
className="unselectable pointer ml-2 text-muted font-italic"
data-tippy-content={i18n.t("locked")}
>
<Icon icon="lock" classes="icon-inline text-danger" />
</small>
)}
{post.featured_community && (
<small
className="unselectable pointer ml-2 text-muted font-italic"
data-tippy-content={i18n.t("featured")}
>
<Icon icon="pin" classes="icon-inline text-primary" />
</small>
)}
{post.featured_local && (
<small
className="unselectable pointer ml-2 text-muted font-italic"
data-tippy-content={i18n.t("featured")}
>
<Icon icon="pin" classes="icon-inline text-secondary" />
</small>
)}
{post.nsfw && (
<small className="ml-2 badge text-bg-danger">{i18n.t("nsfw")}</small>
)}
</div>
);
}
@ -631,11 +621,11 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
const post = this.postView.post;
return (
<div className="d-flex align-items-center justify-content-start flex-wrap text-muted font-weight-bold mb-1">
<div className="d-flex align-items-center justify-content-start flex-wrap text-muted font-weight-bold">
{this.commentsButton}
{canShare() && (
<button
className="btn btn-link"
className="btn btn-sm btn-link"
onClick={linkEvent(this, this.handleShare)}
type="button"
>
@ -654,12 +644,23 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
{mobile && !this.props.viewOnly && this.mobileVotes}
{UserService.Instance.myUserInfo &&
!this.props.viewOnly &&
this.postActions(mobile)}
this.postActions()}
</div>
);
}
postActions(mobile = false) {
get hasAdvancedButtons() {
return (
this.myPost ||
(this.showBody && this.postView.post.body) ||
amMod(this.props.moderators) ||
amAdmin() ||
this.canMod_ ||
this.canAdmin_
);
}
postActions() {
// Possible enhancement: Priority+ pattern instead of just hard coding which get hidden behind the show more button.
// Possible enhancement: Make each button a component.
const post_view = this.postView;
@ -667,37 +668,53 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
<>
{this.saveButton}
{this.crossPostButton}
{mobile && this.showMoreButton}
{(!mobile || this.state.showAdvanced) && (
<>
{!this.myPost && (
<>
{this.reportButton}
{this.blockButton}
</>
)}
{this.myPost && (this.showBody || this.state.showAdvanced) && (
<>
{this.editButton}
{this.deleteButton}
</>
)}
</>
{this.showBody && post_view.post.body && this.viewSourceButton}
{this.hasAdvancedButtons && (
<div className="dropdown">
<button
className="btn btn-link btn-animate text-muted py-0 dropdown-toggle"
onClick={linkEvent(this, this.handleShowAdvanced)}
data-tippy-content={i18n.t("more")}
data-bs-toggle="dropdown"
aria-expanded="false"
aria-controls="advancedButtonsDropdown"
aria-label={i18n.t("more")}
>
<Icon icon="more-vertical" inline />
</button>
<ul className="dropdown-menu" id="advancedButtonsDropdown">
{!this.myPost ? (
<>
<li>{this.reportButton}</li>
<li>{this.blockButton}</li>
</>
) : (
<>
<li>{this.editButton}</li>
<li>{this.deleteButton}</li>
</>
)}
{/* Any mod can do these, not limited to hierarchy*/}
{(amMod(this.props.moderators) || amAdmin()) && (
<>
<li>
<hr className="dropdown-divider" />
</li>
<li>{this.lockButton}</li>
{this.featureButtons}
</>
)}
{(this.canMod_ || this.canAdmin_) && (
<li>{this.modRemoveButton}</li>
)}
</ul>
</div>
)}
{this.state.showAdvanced && (
<>
{this.showBody && post_view.post.body && this.viewSourceButton}
{/* Any mod can do these, not limited to hierarchy*/}
{(amMod(this.props.moderators) || amAdmin()) && (
<>
{this.lockButton}
{this.featureButton}
</>
)}
{(this.canMod_ || this.canAdmin_) && <>{this.modRemoveButton}</>}
</>
)}
{!mobile && this.showMoreButton}
</>
);
}
@ -706,7 +723,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
const post_view = this.postView;
return (
<Link
className="btn btn-link text-muted py-0 pl-0 text-muted"
className="btn btn-link text-muted pl-0 text-muted"
title={i18n.t("number_of_comments", {
count: Number(post_view.counts.comments),
formattedCount: Number(post_view.counts.comments),
@ -846,12 +863,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get reportButton() {
return (
<button
className="btn btn-link btn-animate text-muted py-0"
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handleShowReportDialog)}
data-tippy-content={i18n.t("show_report_dialog")}
aria-label={i18n.t("show_report_dialog")}
>
<Icon icon="flag" inline />
<Icon classes="mr-1" icon="flag" inline />
{i18n.t("create_report")}
</button>
);
}
@ -859,12 +876,16 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get blockButton() {
return (
<button
className="btn btn-link btn-animate text-muted py-0"
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handleBlockPersonClick)}
data-tippy-content={i18n.t("block_user")}
aria-label={i18n.t("block_user")}
>
{this.state.blockLoading ? <Spinner /> : <Icon icon="slash" inline />}
{this.state.blockLoading ? (
<Spinner />
) : (
<Icon classes="mr-1" icon="slash" inline />
)}
{i18n.t("block_user")}
</button>
);
}
@ -872,12 +893,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get editButton() {
return (
<button
className="btn btn-link btn-animate text-muted py-0"
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handleEditClick)}
data-tippy-content={i18n.t("edit")}
aria-label={i18n.t("edit")}
>
<Icon icon="edit" inline />
<Icon classes="mr-1" icon="edit" inline />
{i18n.t("edit")}
</button>
);
}
@ -887,37 +908,26 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
const label = !deleted ? i18n.t("delete") : i18n.t("restore");
return (
<button
className="btn btn-link btn-animate text-muted py-0"
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handleDeleteClick)}
data-tippy-content={label}
aria-label={label}
>
{this.state.deleteLoading ? (
<Spinner />
) : (
<Icon
icon="trash"
classes={classNames({ "text-danger": deleted })}
inline
/>
<>
<Icon
icon="trash"
classes={classNames("mr-1", { "text-danger": deleted })}
inline
/>
{label}
</>
)}
</button>
);
}
get showMoreButton() {
return (
<button
className="btn btn-link btn-animate text-muted py-0"
onClick={linkEvent(this, this.handleShowAdvanced)}
data-tippy-content={i18n.t("more")}
aria-label={i18n.t("more")}
>
<Icon icon="more-vertical" inline />
</button>
);
}
get viewSourceButton() {
return (
<button
@ -940,25 +950,27 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
const label = locked ? i18n.t("unlock") : i18n.t("lock");
return (
<button
className="btn btn-link btn-animate text-muted py-0"
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handleModLock)}
data-tippy-content={label}
aria-label={label}
>
{this.state.lockLoading ? (
<Spinner />
) : (
<Icon
icon="lock"
classes={classNames({ "text-danger": locked })}
inline
/>
<>
<Icon
icon="lock"
classes={classNames("mr-1", { "text-danger": locked })}
inline
/>
{label}
</>
)}
</button>
);
}
get featureButton() {
get featureButtons() {
const featuredCommunity = this.postView.post.featured_community;
const labelCommunity = featuredCommunity
? i18n.t("unfeature_from_community")
@ -969,48 +981,56 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
? i18n.t("unfeature_from_local")
: i18n.t("feature_in_local");
return (
<span>
<button
className="btn btn-link btn-animate text-muted py-0 pl-0"
onClick={linkEvent(this, this.handleModFeaturePostCommunity)}
data-tippy-content={labelCommunity}
aria-label={labelCommunity}
>
{this.state.featureCommunityLoading ? (
<Spinner />
) : (
<span>
<Icon
icon="pin"
classes={classNames({ "text-success": featuredCommunity })}
inline
/>
{i18n.t("community")}
</span>
)}
</button>
{amAdmin() && (
<>
<li>
<button
className="btn btn-link btn-animate text-muted py-0"
onClick={linkEvent(this, this.handleModFeaturePostLocal)}
data-tippy-content={labelLocal}
aria-label={labelLocal}
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handleModFeaturePostCommunity)}
data-tippy-content={labelCommunity}
aria-label={labelCommunity}
>
{this.state.featureLocalLoading ? (
{this.state.featureCommunityLoading ? (
<Spinner />
) : (
<span>
<>
<Icon
icon="pin"
classes={classNames({ "text-success": featuredLocal })}
classes={classNames("mr-1", {
"text-success": featuredCommunity,
})}
inline
/>
{i18n.t("local")}
</span>
{i18n.t("community")}
</>
)}
</button>
)}
</span>
</li>
<li>
{amAdmin() && (
<button
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(this, this.handleModFeaturePostLocal)}
data-tippy-content={labelLocal}
aria-label={labelLocal}
>
{this.state.featureLocalLoading ? (
<Spinner />
) : (
<>
<Icon
icon="pin"
classes={classNames("mr-1", {
"text-success": featuredLocal,
})}
inline
/>
{i18n.t("local")}
</>
)}
</button>
)}
</li>
</>
);
}
@ -1018,7 +1038,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
const removed = this.postView.post.removed;
return (
<button
className="btn btn-link btn-animate text-muted py-0"
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
onClick={linkEvent(
this,
!removed ? this.handleModRemoveShow : this.handleModRemoveSubmit

View file

@ -96,9 +96,7 @@ export class PostListings extends Component<PostListingsProps, any> {
onAddAdmin={this.props.onAddAdmin}
onTransferCommunity={this.props.onTransferCommunity}
/>
{idx + 1 !== this.posts.length && (
<hr className="my-3 border border-primary" />
)}
{idx + 1 !== this.posts.length && <hr className="my-3" />}
</>
))
) : (

View file

@ -1202,6 +1202,13 @@
dependencies:
regenerator-runtime "^0.13.11"
"@babel/runtime@^7.20.7":
version "7.22.5"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.5.tgz#8564dd588182ce0047d55d7a75e93921107b57ec"
integrity sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA==
dependencies:
regenerator-runtime "^0.13.11"
"@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.21.9":
version "7.21.9"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.21.9.tgz#bf8dad2859130ae46088a99c1f265394877446fb"
@ -2159,6 +2166,13 @@ argparse@^2.0.1:
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
aria-query@^5.1.3:
version "5.2.1"
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.2.1.tgz#bc285d9d654d1df121bcd0c134880d415ca67c15"
integrity sha512-7uFg4b+lETFgdaJyETnILsXgnnzVnkHcgRbwbPwevm5x/LmUlt3MjczMRe1zg824iBgXZNRPTBftNYyRSKLp2g==
dependencies:
dequal "^2.0.3"
array-buffer-byte-length@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz#fabe8bc193fea865f317fe7807085ee0dee5aead"
@ -2177,7 +2191,7 @@ array-flatten@^2.1.2:
resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.2.tgz#24ef80a28c1a893617e2149b0c6d0d788293b099"
integrity sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==
array-includes@^3.1.5:
array-includes@^3.1.5, array-includes@^3.1.6:
version "3.1.6"
resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.6.tgz#9e9e720e194f198266ba9e18c29e6a9b0e4b225f"
integrity sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==
@ -2205,6 +2219,16 @@ array-uniq@^1.0.1:
resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6"
integrity sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==
array.prototype.flatmap@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz#1aae7903c2100433cb8261cd4ed310aab5c4a183"
integrity sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==
dependencies:
call-bind "^1.0.2"
define-properties "^1.1.4"
es-abstract "^1.20.4"
es-shim-unscopables "^1.0.0"
array.prototype.reduce@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/array.prototype.reduce/-/array.prototype.reduce-1.0.5.tgz#6b20b0daa9d9734dd6bc7ea66b5bbce395471eac"
@ -2244,6 +2268,11 @@ assert-plus@1.0.0, assert-plus@^1.0.0:
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==
ast-types-flow@^0.0.7:
version "0.0.7"
resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad"
integrity sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==
astral-regex@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
@ -2284,6 +2313,18 @@ aws4@^1.8.0:
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.12.0.tgz#ce1c9d143389679e253b314241ea9aa5cec980d3"
integrity sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==
axe-core@^4.6.2:
version "4.7.2"
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.7.2.tgz#040a7342b20765cb18bb50b628394c21bccc17a0"
integrity sha512-zIURGIS1E1Q4pcrMjp+nnEh+16G56eG/MUllJH8yEvw7asDo7Ac9uhC9KIH5jzpITueEZolfYglnCGIuSBz39g==
axobject-query@^3.1.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a"
integrity sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==
dependencies:
dequal "^2.0.3"
babel-loader@^9.1.2:
version "9.1.2"
resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-9.1.2.tgz#a16a080de52d08854ee14570469905a5fc00d39c"
@ -2446,16 +2487,16 @@ bonjour-service@^1.0.11:
fast-deep-equal "^3.1.3"
multicast-dns "^7.2.5"
"bootstrap-v4@npm:bootstrap@^4.6.2":
version "4.6.2"
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.6.2.tgz#8e0cd61611728a5bf65a3a2b8d6ff6c77d5d7479"
integrity sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==
bootstrap@^5.2.3:
version "5.3.0"
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.0.tgz#0718a7cc29040ee8dbf1bd652b896f3436a87c29"
integrity sha512-UnBV3E3v4STVNQdms6jSGO2CvOkjUMdDAVR2V5N4uCMdaIkaQjbcEAMqRimDHIs4uqBYzDAKCQwCB+97tJgHQw==
bootswatch@^5.2.3:
version "5.3.0"
resolved "https://registry.yarnpkg.com/bootswatch/-/bootswatch-5.3.0.tgz#7c7dd50bbe8519b0c6dbe01f4f9c3100b60228bd"
integrity sha512-ga2hHognDrh5h3+CaBBug6ktx3MTlnDzH57s+Mvjt9ZcNxqwpK+m3sE3YIUSr8zf2iG05elOb1mnqqcdbce2ow==
boxen@^1.2.1:
version "1.3.0"
resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b"
@ -3152,6 +3193,11 @@ cyclist@^1.0.1:
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.2.tgz#673b5f233bf34d8e602b949429f8171d9121bea3"
integrity sha512-0sVXIohTfLqVIW3kb/0n6IiWF3Ifj5nm2XaSrLq2DI6fKIGa2fYAZdk917rUneaeLVpYfFcyXE2ft0fe3remsA==
damerau-levenshtein@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==
dashdash@^1.12.0:
version "1.14.1"
resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
@ -3319,6 +3365,11 @@ depd@~1.1.2:
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==
dequal@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
destroy@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
@ -3696,6 +3747,28 @@ eslint-plugin-inferno@^7.32.2:
resolve "^2.0.0-next.4"
semver "^7.3.8"
eslint-plugin-jsx-a11y@^6.7.1:
version "6.7.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.7.1.tgz#fca5e02d115f48c9a597a6894d5bcec2f7a76976"
integrity sha512-63Bog4iIethyo8smBklORknVjB0T2dwB8Mr/hIC+fBS0uyHdYYpzM/Ed+YC8VxTjlXHEWFOdmgwcDn1U2L9VCA==
dependencies:
"@babel/runtime" "^7.20.7"
aria-query "^5.1.3"
array-includes "^3.1.6"
array.prototype.flatmap "^1.3.1"
ast-types-flow "^0.0.7"
axe-core "^4.6.2"
axobject-query "^3.1.1"
damerau-levenshtein "^1.0.8"
emoji-regex "^9.2.2"
has "^1.0.3"
jsx-ast-utils "^3.3.3"
language-tags "=1.0.5"
minimatch "^3.1.2"
object.entries "^1.1.6"
object.fromentries "^2.0.6"
semver "^6.3.0"
eslint-plugin-prettier@^4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz#651cbb88b1dab98bfd42f017a12fa6b2d993f94b"
@ -5590,6 +5663,18 @@ klona@^2.0.6:
resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.6.tgz#85bffbf819c03b2f53270412420a4555ef882e22"
integrity sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==
language-subtag-registry@~0.3.2:
version "0.3.22"
resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz#2e1500861b2e457eba7e7ae86877cbd08fa1fd1d"
integrity sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==
language-tags@=1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/language-tags/-/language-tags-1.0.5.tgz#d321dbc4da30ba8bf3024e040fa5c14661f9193a"
integrity sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==
dependencies:
language-subtag-registry "~0.3.2"
latest-version@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15"