├── .husky ├── .gitignore └── pre-commit ├── .dockerignore ├── .github ├── CODEOWNERS ├── pull_request_template.md └── ISSUE_TEMPLATE │ ├── config.yml │ ├── FEATURE_REQUEST.yml │ └── BUG_REPORT.yml ├── src ├── shared │ ├── version.ts │ ├── build-date.ts │ ├── components │ │ ├── common │ │ │ ├── tables.tsx │ │ │ ├── content-actions │ │ │ │ ├── post-action-dropdown.tsx │ │ │ │ ├── comment-action-dropdown.tsx │ │ │ │ ├── action-button.tsx │ │ │ │ └── create-item-buttons.tsx │ │ │ ├── registration-state-radios.tsx │ │ │ ├── anonymous-guard.tsx │ │ │ ├── loading-ellipses.tsx │ │ │ ├── paginator.tsx │ │ │ ├── emoji-mart.tsx │ │ │ ├── error-guard.tsx │ │ │ ├── banner-icon-header.tsx │ │ │ ├── language-list.tsx │ │ │ ├── auth-guard.tsx │ │ │ ├── progress-bar.tsx │ │ │ ├── radio-button-group.tsx │ │ │ ├── post-listing-mode-select.tsx │ │ │ ├── blocking-keywords-textarea.tsx │ │ │ ├── tabs.tsx │ │ │ ├── html-tags.tsx │ │ │ ├── url-list-textarea.tsx │ │ │ ├── icon.tsx │ │ │ ├── modal │ │ │ │ ├── display-modal.tsx │ │ │ │ ├── image-upload-confirm-modal.tsx │ │ │ │ └── confirmation-modal.tsx │ │ │ ├── post-hidden-select.tsx │ │ │ ├── paginator-cursor.tsx │ │ │ ├── post-or-comment-type-select.tsx │ │ │ ├── moment-time.tsx │ │ │ ├── loading-skeleton.tsx │ │ │ ├── emoji-picker.tsx │ │ │ ├── notification-select.tsx │ │ │ ├── pending-follow.tsx │ │ │ └── media-uploads.tsx │ │ ├── app │ │ │ ├── styles.scss │ │ │ ├── code-theme.tsx │ │ │ ├── error-page.tsx │ │ │ └── footer.tsx │ │ ├── home │ │ │ ├── banned-dialog.tsx │ │ │ ├── legal.tsx │ │ │ ├── federation-mode-select.tsx │ │ │ ├── donation-dialog.tsx │ │ │ ├── oauth │ │ │ │ └── oauth-provider-list-item.tsx │ │ │ └── instance-allow-form.tsx │ │ ├── mixins │ │ │ ├── tippy-mixin.ts │ │ │ └── modal-mixin.ts │ │ ├── person │ │ │ ├── cake-day.tsx │ │ │ ├── verify-email.tsx │ │ │ └── notification-modlog-item.tsx │ │ ├── community │ │ │ ├── community-header.tsx │ │ │ ├── create-community.tsx │ │ │ └── community-link.tsx │ │ ├── post │ │ │ ├── metadata-card.tsx │ │ │ └── post-listing-list.tsx │ │ └── multi-community │ │ │ ├── multi-community-link.tsx │ │ │ ├── create-multi-community.tsx │ │ │ └── multi-community-entry-form.tsx │ ├── build-config.d.ts │ ├── services │ │ ├── index.ts │ │ ├── FirstLoadService.ts │ │ ├── UnreadCounterService.ts │ │ ├── UserService.ts │ │ └── HttpService.ts │ ├── build-config.js │ └── utils │ │ ├── media.ts │ │ ├── env.ts │ │ ├── dynamic-imports.ts │ │ ├── tippy.ts │ │ ├── config.ts │ │ ├── roles.ts │ │ └── date.ts ├── assets │ ├── css │ │ ├── themes │ │ │ ├── _variables.darkly-compact.scss │ │ │ ├── _variables.litely-compact.scss │ │ │ ├── darkly.scss │ │ │ ├── litely.scss │ │ │ ├── darkly-red.scss │ │ │ ├── litely-red.scss │ │ │ ├── _variables.darkly-red.scss │ │ │ ├── darkly-pureblack.scss │ │ │ ├── _variables.litely-red.scss │ │ │ ├── vaporwave-light.scss │ │ │ ├── _variables.vaporwave-light.scss │ │ │ ├── i386-dark.scss │ │ │ ├── _variables.scss │ │ │ ├── RBlind-Dark.scss │ │ │ ├── RBlind-Light.scss │ │ │ ├── vaporwave-dark.scss │ │ │ ├── _variables.vaporwave-dark.scss │ │ │ ├── _variables.vaporwave.scss │ │ │ ├── darkly-compact.scss │ │ │ ├── litely-compact.scss │ │ │ ├── _variables.litely.scss │ │ │ ├── _variables.i386-dark.scss │ │ │ ├── _variables.RBlind-Dark.scss │ │ │ ├── _variables.RBlind-Light.scss │ │ │ └── _variables.darkly.scss │ │ └── code-themes │ │ │ ├── atom-one-dark.css │ │ │ └── atom-one-light.css │ ├── icons │ │ ├── icon-72x72.png │ │ ├── icon-96x96.png │ │ ├── icon-128x128.png │ │ ├── icon-144x144.png │ │ ├── icon-152x152.png │ │ ├── icon-192x192.png │ │ ├── icon-384x384.png │ │ ├── icon-512x512.png │ │ └── apple-touch-icon.png │ └── images │ │ └── broken-image-fallback.png ├── server │ ├── utils │ │ ├── fetch-icon-png.ts │ │ ├── has-jwt-cookie.ts │ │ ├── get-error-page-data.ts │ │ ├── set-forwarded-headers.ts │ │ ├── build-themes-list.ts │ │ ├── dev-env.ts │ │ └── generate-manifest-json.ts │ ├── handlers │ │ ├── themes-list-handler.ts │ │ ├── service-worker-handler.ts │ │ ├── robots-handler.ts │ │ ├── security-handler.ts │ │ ├── manifest-handler.ts │ │ ├── code-theme-handler.ts │ │ └── theme-handler.ts │ └── middleware.ts ├── embedded │ └── index.ts └── client │ └── index.tsx ├── pnpm-workspace.yaml ├── .prettierrc.json ├── CONTRIBUTING.md ├── .gitmodules ├── .prettierignore ├── scripts ├── test_deploy.sh ├── generate_changelog.bash ├── test.sh ├── accessibility_tests.sh ├── deploy.sh └── update_translations.sh ├── renovate.json ├── .gitignore ├── .babelrc ├── tsconfig.json ├── dev.dockerfile ├── eslint.config.mjs ├── .woodpecker.yml ├── cliff.toml ├── Dockerfile └── generate_translations.js /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint-staged 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @dessalines @SleeplessOne1917 @matc-pub @Nutomic 2 | -------------------------------------------------------------------------------- /src/shared/version.ts: -------------------------------------------------------------------------------- 1 | export const VERSION = "unknown version" as string; 2 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.darkly-compact.scss: -------------------------------------------------------------------------------- 1 | @import "variables.darkly"; 2 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.litely-compact.scss: -------------------------------------------------------------------------------- 1 | @import "variables.litely"; 2 | -------------------------------------------------------------------------------- /src/shared/build-date.ts: -------------------------------------------------------------------------------- 1 | export const BUILD_DATE_ISO8601 = "2024-01-22T13:58:48Z"; 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - '@parcel/watcher' 3 | - inferno 4 | - sharp 5 | -------------------------------------------------------------------------------- /src/assets/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/icon-72x72.png -------------------------------------------------------------------------------- /src/assets/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/icon-96x96.png -------------------------------------------------------------------------------- /src/shared/components/common/tables.tsx: -------------------------------------------------------------------------------- 1 | export const TableHr = () =>
; 2 | -------------------------------------------------------------------------------- /src/assets/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/icon-128x128.png -------------------------------------------------------------------------------- /src/assets/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/icon-144x144.png -------------------------------------------------------------------------------- /src/assets/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/icon-152x152.png -------------------------------------------------------------------------------- /src/assets/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/icon-192x192.png -------------------------------------------------------------------------------- /src/assets/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/icon-384x384.png -------------------------------------------------------------------------------- /src/assets/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/icon-512x512.png -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "plugins": ["prettier-plugin-packagejson"], 4 | "semi": true 5 | } 6 | -------------------------------------------------------------------------------- /src/assets/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /src/assets/css/themes/darkly.scss: -------------------------------------------------------------------------------- 1 | @import "variables.darkly"; 2 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 3 | -------------------------------------------------------------------------------- /src/assets/css/themes/litely.scss: -------------------------------------------------------------------------------- 1 | @import "variables.litely"; 2 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 3 | -------------------------------------------------------------------------------- /src/assets/css/themes/darkly-red.scss: -------------------------------------------------------------------------------- 1 | @import "variables.darkly-red"; 2 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 3 | -------------------------------------------------------------------------------- /src/assets/css/themes/litely-red.scss: -------------------------------------------------------------------------------- 1 | @import "variables.litely-red"; 2 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 3 | -------------------------------------------------------------------------------- /src/assets/images/broken-image-fallback.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/images/broken-image-fallback.png -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | See [here](https://join-lemmy.org/docs/contributors/01-overview.html) for contributing Instructions. 4 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.darkly-red.scss: -------------------------------------------------------------------------------- 1 | @import "variables.darkly"; 2 | 3 | $primary: $red; 4 | $info: $blue; 5 | $light: $gray-800; 6 | -------------------------------------------------------------------------------- /src/assets/css/themes/darkly-pureblack.scss: -------------------------------------------------------------------------------- 1 | @import "variables.darkly-pureblack"; 2 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 3 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lemmy-translations"] 2 | path = lemmy-translations 3 | url = https://github.com/lemmynet/lemmy-translations 4 | branch = main 5 | -------------------------------------------------------------------------------- /src/shared/build-config.d.ts: -------------------------------------------------------------------------------- 1 | export const bundledSyntaxHighlighters: ["plaintext", ...string[]]; 2 | export const lazySyntaxHighlighters: string[] | "*"; 3 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.litely-red.scss: -------------------------------------------------------------------------------- 1 | @import "variables.litely"; 2 | 3 | $primary: $red; 4 | $info: $blue; 5 | $danger: darken($primary, 24%); 6 | -------------------------------------------------------------------------------- /src/server/utils/fetch-icon-png.ts: -------------------------------------------------------------------------------- 1 | export async function fetchIconPng(iconUrl: string) { 2 | return await fetch(iconUrl) 3 | .then(res => res.blob()) 4 | .then(blob => blob.arrayBuffer()); 5 | } 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | src/shared/translations 2 | lemmy-translations 3 | src/assets/css/themes/*.css 4 | src/assets/css/code-themes/*.css 5 | src/assets/emojis.json 6 | stats.json 7 | dist 8 | pnpm-lock.yaml 9 | pnpm-workspace.yaml 10 | -------------------------------------------------------------------------------- /scripts/test_deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | CWD="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" 5 | cd "$CWD/../" 6 | 7 | sudo docker build . --tag dessalines/lemmy-ui:dev 8 | sudo docker push dessalines/lemmy-ui:dev 9 | -------------------------------------------------------------------------------- /scripts/generate_changelog.bash: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | CWD="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" 5 | cd "$CWD/../" 6 | 7 | # Adding to CHANGELOG.md 8 | git cliff --output CHANGELOG.md 9 | prettier -w CHANGELOG.md 10 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"], 4 | "schedule": ["every weekend"], 5 | "automerge": true, 6 | "rebaseWhen": "conflicted", 7 | "ignoreDeps": ["lemmy-js-client", "i18next"] 8 | } 9 | -------------------------------------------------------------------------------- /src/server/handlers/themes-list-handler.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from "express"; 2 | import { buildThemeList } from "../utils/build-themes-list"; 3 | 4 | export default async ({ res }: { res: Response }) => { 5 | res.type("json").send(JSON.stringify(await buildThemeList())); 6 | }; 7 | -------------------------------------------------------------------------------- /src/shared/services/index.ts: -------------------------------------------------------------------------------- 1 | export { FirstLoadService } from "./FirstLoadService"; 2 | export { HttpService } from "./HttpService"; 3 | export { I18NextService } from "./I18NextService"; 4 | export { UserService } from "./UserService"; 5 | export { UnreadCounterService } from "./UnreadCounterService"; 6 | -------------------------------------------------------------------------------- /src/assets/css/themes/vaporwave-light.scss: -------------------------------------------------------------------------------- 1 | @import "variables.vaporwave-light"; 2 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 3 | 4 | .form-control::placeholder { 5 | color: $gray-500; 6 | } 7 | 8 | .dropdown-item:hover:not(.active) { 9 | background-color: $secondary; 10 | } 11 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 5 | 6 | ## Screenshots 7 | 8 | 9 | 10 | ### Before 11 | 12 | ### After 13 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.vaporwave-light.scss: -------------------------------------------------------------------------------- 1 | @import "./variables.vaporwave"; 2 | 3 | // Colors 4 | $gray-600: #6c757d; 5 | $gray-700: #495057; 6 | $gray-800: #343a40; 7 | $gray-900: #212529; 8 | 9 | $light: darken($gray-300, 1.5); 10 | 11 | $body-bg: $gray-100; 12 | $body-color: $gray-700; 13 | $text-muted: $gray-500; 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Question 3 | url: https://lemmy.ml/c/lemmy_support 4 | about: Please ask and answer general questions here. 5 | - name: Technical Discussion 6 | url: https://github.com/LemmyNet/lemmy-ui/discussions 7 | about: Please discuss technical topics with other contributors here. 8 | -------------------------------------------------------------------------------- /src/shared/components/common/content-actions/post-action-dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { InfernoNode } from "inferno"; 2 | import ContentActionDropdown, { 3 | ContentPostProps, 4 | } from "./content-action-dropdown"; 5 | 6 | export default ( 7 | props: Omit, 8 | ): InfernoNode => ; 9 | -------------------------------------------------------------------------------- /src/shared/components/common/content-actions/comment-action-dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { InfernoNode } from "inferno"; 2 | import ContentActionDropdown, { 3 | ContentCommentProps, 4 | } from "./content-action-dropdown"; 5 | 6 | export default ( 7 | props: Omit, 8 | ): InfernoNode => ; 9 | -------------------------------------------------------------------------------- /src/shared/components/app/styles.scss: -------------------------------------------------------------------------------- 1 | // Custom css 2 | @import "../../../../node_modules/tributejs/dist/tribute.css"; 3 | @import "../../../../node_modules/toastify-js/src/toastify.css"; 4 | @import "../../../../node_modules/tippy.js/dist/tippy.css"; 5 | @import "../../../../node_modules/bootstrap/dist/css/bootstrap-utilities.min.css"; 6 | @import "../../../assets/css/main.css"; 7 | -------------------------------------------------------------------------------- /src/assets/css/themes/i386-dark.scss: -------------------------------------------------------------------------------- 1 | @import "variables.i386-dark"; 2 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 3 | 4 | .btn-outline-secondary { 5 | color: $gray-500; 6 | } 7 | 8 | .dropdown-item.active, 9 | .dropdown-item:hover, 10 | option:disabled { 11 | color: $secondary; 12 | } 13 | 14 | .input-group-text { 15 | background: $gray-500; 16 | } 17 | -------------------------------------------------------------------------------- /src/server/utils/has-jwt-cookie.ts: -------------------------------------------------------------------------------- 1 | import * as cookie from "cookie"; 2 | import { authCookieName } from "@utils/config"; 3 | import { IncomingHttpHeaders } from "http"; 4 | 5 | export function getJwtCookie(headers: IncomingHttpHeaders): string | undefined { 6 | return headers.cookie 7 | ? cookie.parse(headers.cookie)[authCookieName] // This can actually be undefined 8 | : undefined; 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .fusebox 3 | _site 4 | .alm 5 | .history 6 | .git 7 | build 8 | .build 9 | .idea 10 | .jshintrc 11 | .nyc_output 12 | .sass-cache 13 | .vscode 14 | coverage 15 | jsconfig.json 16 | Gemfile.lock 17 | node_modules 18 | .DS_Store 19 | *.map 20 | *.log 21 | *.swp 22 | *~ 23 | test/data/result.json 24 | .yalc 25 | yalc.lock 26 | 27 | package-lock.json 28 | *.orig 29 | 30 | src/shared/translations 31 | 32 | stats.json 33 | 34 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | CWD="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" 5 | cd "$CWD/../" 6 | 7 | # Use this to develop on voyager.lemmy.ml 8 | export LEMMY_UI_BACKEND_REMOTE=voyager.lemmy.ml 9 | 10 | # Use this to develop locally. Change TEST.TLD to your test server. 11 | # export LEMMY_UI_BACKEND_INTERNAL=0.0.0.0:8536 12 | # export LEMMY_UI_BACKEND_EXTERNAL=TEST.TLD:8536 13 | 14 | pnpm i 15 | pnpm dev 16 | -------------------------------------------------------------------------------- /scripts/accessibility_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | ignores="WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail" 5 | base_url="http://192.168.50.60:1234" 6 | 7 | test_urls=( 8 | $base_url 9 | $base_url/communities 10 | $base_url/login 11 | $base_url/search 12 | $base_url/c/announcements 13 | $base_url/u/dessalines 14 | $base_url/post/34286 15 | ) 16 | 17 | for i in "${test_urls[@]}"; do 18 | pa11y --ignore="$ignores" "$i" 19 | done 20 | -------------------------------------------------------------------------------- /src/server/handlers/service-worker-handler.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from "express"; 2 | import path from "path"; 3 | 4 | export default async ({ res }: { res: Response }) => { 5 | res 6 | .setHeader("Content-Type", "application/javascript") 7 | .sendFile( 8 | path.resolve( 9 | `./dist/service-worker${ 10 | process.env.NODE_ENV === "development" ? "-development" : "" 11 | }.js`, 12 | ), 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | CWD="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" 5 | cd "$CWD/../" 6 | 7 | new_tag="$1" 8 | 9 | # Old deploy 10 | # sudo docker build . --tag dessalines/lemmy-ui:$new_tag --platform=linux/amd64 --push 11 | # sudo docker build . --tag dessalines/lemmy-ui:$new_tag --platform=linux/amd64 12 | # sudo docker push dessalines/lemmy-ui:$new_tag 13 | 14 | # Upgrade version 15 | pnpm version $new_tag 16 | git push 17 | 18 | git tag $new_tag 19 | git push origin $new_tag 20 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.scss: -------------------------------------------------------------------------------- 1 | $link-decoration: none; 2 | $min-contrast-ratio: 3; 3 | $font-size-root: 100%; 4 | :root { 5 | --comment-border-width: 2px; 6 | --comment-node-1-color: hsla(0, 35%, 50%, 0.5); 7 | --comment-node-2-color: hsla(50, 35%, 50%, 0.5); 8 | --comment-node-3-color: hsla(100, 35%, 50%, 0.5); 9 | --comment-node-4-color: hsla(150, 35%, 50%, 0.5); 10 | --comment-node-5-color: hsla(200, 35%, 50%, 0.5); 11 | --comment-node-6-color: hsla(250, 35%, 50%, 0.5); 12 | --comment-node-7-color: hsla(300, 35%, 50%, 0.5); 13 | } 14 | -------------------------------------------------------------------------------- /src/assets/css/themes/RBlind-Dark.scss: -------------------------------------------------------------------------------- 1 | @import "variables.darkly-compact"; 2 | @import "variables.RBlind-Dark"; 3 | 4 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 5 | @import "./rblind/RBlind"; 6 | 7 | // make the down arrow on the dropdown select buttons white instead of gray 8 | .form-select { 9 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/css/themes/RBlind-Light.scss: -------------------------------------------------------------------------------- 1 | @import "variables.litely-compact"; 2 | @import "variables.RBlind-Light"; 3 | 4 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 5 | @import "./rblind/RBlind"; 6 | 7 | // make the down arrow on the dropdown select buttons white instead of gray 8 | .form-select { 9 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); 10 | } 11 | -------------------------------------------------------------------------------- /src/server/handlers/robots-handler.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from "express"; 2 | 3 | export default async ({ res }: { res: Response }) => { 4 | res.setHeader("content-type", "text/plain; charset=utf-8"); 5 | 6 | res.send(`User-Agent: * 7 | Disallow: /login 8 | Disallow: /login_reset 9 | Disallow: /signup 10 | Disallow: /settings 11 | Disallow: /create_community 12 | Disallow: /create_post 13 | Disallow: /create_private_message 14 | Disallow: /notifications 15 | Disallow: /setup 16 | Disallow: /admin 17 | Disallow: /password_change 18 | Disallow: /search 19 | Disallow: /modlog 20 | Disallow: /api 21 | Crawl-delay: 60`); 22 | }; 23 | -------------------------------------------------------------------------------- /scripts/update_translations.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | CWD="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" 5 | cd "$CWD/../" 6 | 7 | pushd ../lemmy-translations 8 | git fetch weblate 9 | git merge weblate/main 10 | git push 11 | popd 12 | 13 | # look for unused translations 14 | for langfile in lemmy-translations/translations/*.json; do 15 | lang=$(basename $langfile .json) 16 | if ! grep -q "\"./translations/$lang\"" src/shared/services/I18NextService.ts; then 17 | echo "Unused language $lang" 18 | fi 19 | done 20 | 21 | git submodule update --remote 22 | git add lemmy-translations 23 | git commit -m"Updating translations." 24 | git push 25 | -------------------------------------------------------------------------------- /src/server/utils/get-error-page-data.ts: -------------------------------------------------------------------------------- 1 | import { ErrorPageData } from "@utils/types"; 2 | import { GetSiteResponse } from "lemmy-js-client"; 3 | 4 | export function getErrorPageData(error: Error, site?: GetSiteResponse) { 5 | const errorPageData: ErrorPageData = {}; 6 | 7 | if (site) { 8 | errorPageData.error = error.name; 9 | } 10 | 11 | const adminMatrixIds = site?.admins 12 | .map(({ person: { matrix_user_id } }) => matrix_user_id) 13 | .filter(id => id) as string[] | undefined; 14 | 15 | if (adminMatrixIds && adminMatrixIds.length > 0) { 16 | errorPageData.adminMatrixIds = adminMatrixIds; 17 | } 18 | 19 | return errorPageData; 20 | } 21 | -------------------------------------------------------------------------------- /src/server/handlers/security-handler.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from "express"; 2 | import { BUILD_DATE_ISO8601 } from "../../shared/build-date"; 3 | import { parseISO } from "date-fns"; 4 | 5 | export default async ({ res }: { res: Response }) => { 6 | const buildDatePlusYear = parseISO(BUILD_DATE_ISO8601); 7 | 8 | // Add a year to the build date 9 | buildDatePlusYear.setFullYear(new Date().getFullYear() + 1); 10 | 11 | const yearFromNow = buildDatePlusYear.toISOString(); 12 | 13 | res.setHeader("content-type", "text/plain; charset=utf-8"); 14 | 15 | res.send(`Contact: https://github.com/LemmyNet/lemmy-ui/security/advisories/new 16 | Expires: ${yearFromNow}`); 17 | }; 18 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "compact": false, 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "loose": true, 8 | "browserslistEnv": "package.json" 9 | } 10 | ], 11 | ["@babel/typescript", { "isTSX": true, "allExtensions": true }] 12 | ], 13 | "plugins": [ 14 | ["@babel/plugin-proposal-decorators", { "version": "legacy" }], 15 | [ 16 | "@babel/plugin-transform-runtime", 17 | // version defaults to 7.0.0 for which non-legacy decorators produce duplicate code 18 | { "version": "^7.24.3" } 19 | ], 20 | ["babel-plugin-inferno", { "imports": true }], 21 | ["@babel/plugin-transform-class-properties", { "loose": true }] 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/assets/css/themes/vaporwave-dark.scss: -------------------------------------------------------------------------------- 1 | @import "variables.vaporwave-dark"; 2 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 3 | 4 | .shadow-sm { 5 | background: linear-gradient( 6 | 180deg, 7 | rgba(255, 255, 255, 0.15), 8 | rgba(255, 255, 255, 0) 9 | ); 10 | } 11 | 12 | .navbar { 13 | background: none; 14 | } 15 | 16 | .dropdown-item:hover, 17 | option:disabled { 18 | color: $secondary; 19 | } 20 | 21 | .btn-sm { 22 | margin: 0.25rem; 23 | } 24 | 25 | .form-control::placeholder { 26 | text-shadow: 27 | 0.5px 0.5px 0 $secondary, 28 | 0.5px -0.5px 0 $secondary, 29 | -0.5px 0.5px 0 $secondary, 30 | -0.5px -0.5px 0 $secondary; 31 | } 32 | 33 | .input-group-text { 34 | background: $gray-500; 35 | } 36 | -------------------------------------------------------------------------------- /src/shared/components/home/banned-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { I18NextService } from "@services/index"; 2 | import { formatRelativeDate } from "@utils/date"; 3 | 4 | interface BannedDialogProps { 5 | expires: string | undefined; 6 | } 7 | 8 | export function BannedDialog({ expires }: BannedDialogProps) { 9 | const title = expires 10 | ? I18NextService.i18n.t("banned_dialog_title_temporary", { 11 | expires: formatRelativeDate(expires), 12 | }) 13 | : I18NextService.i18n.t("banned_dialog_title_permanent"); 14 | return ( 15 |
16 |

{title}

17 |
{I18NextService.i18n.t("banned_dialog_body")}
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/shared/components/common/registration-state-radios.tsx: -------------------------------------------------------------------------------- 1 | import { RadioOption, RadioButtonGroup } from "./radio-button-group"; 2 | 3 | export type RegistrationState = "unread" | "all" | "denied"; 4 | 5 | interface RegistrationStateRadiosProps { 6 | state: RegistrationState; 7 | onClick(val: RegistrationState): void; 8 | } 9 | 10 | export function RegistrationStateRadios(props: RegistrationStateRadiosProps) { 11 | const allStates: RadioOption[] = [ 12 | { value: "unread", i18n: "unread" }, 13 | { value: "all", i18n: "all" }, 14 | { value: "denied", i18n: "denied" }, 15 | ]; 16 | return ( 17 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/shared/components/mixins/tippy-mixin.ts: -------------------------------------------------------------------------------- 1 | import { Component, InfernoNode } from "inferno"; 2 | import { cleanupTippy } from "@utils/tippy"; 3 | 4 | export function tippyMixin< 5 | P, 6 | S, 7 | Base extends new (...args: any) => Component, 8 | >(base: Base, _context?: ClassDecoratorContext) { 9 | return class extends base { 10 | componentDidUpdate( 11 | prevProps: P & { children?: InfernoNode }, 12 | prevState: S, 13 | snapshot: any, 14 | ) { 15 | // For conditional rendering, old tippy instances aren't reused 16 | cleanupTippy(); 17 | return super.componentDidUpdate?.(prevProps, prevState, snapshot); 18 | } 19 | 20 | componentWillUnmount() { 21 | cleanupTippy(); 22 | return super.componentWillUnmount?.(); 23 | } 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.vaporwave-dark.scss: -------------------------------------------------------------------------------- 1 | @import "./variables.vaporwave"; 2 | 3 | // Colors 4 | $white: #fff; 5 | $gray-200: #ebebeb; 6 | $gray-600: #888; 7 | $gray-700: #444; 8 | $gray-800: #303030; 9 | $gray-900: #222; 10 | 11 | $light: $gray-700; 12 | $dark: $gray-200; 13 | 14 | $body-bg: $gray-900; 15 | $body-color: $gray-200; 16 | 17 | $card-bg: $body-bg; 18 | $navbar-dark-color: rgba($body-bg, 0.5); 19 | $navbar-light-active-color: rgba($gray-200, 0.9); 20 | $navbar-light-disabled-color: rgba($gray-200, 0.3); 21 | $navbar-light-color: rgba($white, 0.5); 22 | $nav-tabs-link-active-color: $purple; 23 | $input-bg: $gray-600; 24 | $input-color: $white; 25 | $input-disabled-bg: $gray-800; 26 | $input-border-color: $gray-800; 27 | $mark-bg: $gray-600; 28 | $mark-bg-dark: $gray-600; 29 | $pre-color: $gray-200; 30 | -------------------------------------------------------------------------------- /src/server/utils/set-forwarded-headers.ts: -------------------------------------------------------------------------------- 1 | import { IncomingHttpHeaders } from "http"; 2 | import { getJwtCookie } from "./has-jwt-cookie"; 3 | 4 | export function setForwardedHeaders(headers: IncomingHttpHeaders): { 5 | [key: string]: string; 6 | } { 7 | const out: { [key: string]: string } = {}; 8 | 9 | if (headers.host) { 10 | out.host = headers.host; 11 | } 12 | 13 | const realIp = headers["x-real-ip"]; 14 | 15 | if (realIp) { 16 | out["x-real-ip"] = realIp as string; 17 | } 18 | 19 | const forwardedFor = headers["x-forwarded-for"]; 20 | 21 | if (forwardedFor) { 22 | out["x-forwarded-for"] = forwardedFor as string; 23 | } 24 | 25 | const auth = getJwtCookie(headers); 26 | 27 | if (auth) { 28 | out["Authorization"] = `Bearer ${auth}`; 29 | } 30 | 31 | return out; 32 | } 33 | -------------------------------------------------------------------------------- /src/shared/services/FirstLoadService.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from "@utils/browser"; 2 | 3 | export class FirstLoadService { 4 | #isFirstLoad: boolean; 5 | static #instance: FirstLoadService; 6 | 7 | private constructor() { 8 | this.#isFirstLoad = true; 9 | } 10 | 11 | get isFirstLoad() { 12 | const isFirst = this.#isFirstLoad; 13 | if (isFirst) { 14 | this.#isFirstLoad = false; 15 | } 16 | 17 | return isFirst; 18 | } 19 | 20 | falsify() { 21 | this.#isFirstLoad = false; 22 | } 23 | 24 | static get #Instance() { 25 | return this.#instance ?? (this.#instance = new this()); 26 | } 27 | 28 | static get isFirstLoad() { 29 | return !isBrowser() || this.#Instance.isFirstLoad; 30 | } 31 | 32 | static falsify() { 33 | this.#Instance.falsify(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/shared/components/person/cake-day.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "inferno"; 2 | import { I18NextService } from "../../services"; 3 | import { Icon } from "../common/icon"; 4 | import { tippyMixin } from "../mixins/tippy-mixin"; 5 | 6 | interface CakeDayProps { 7 | creatorName: string; 8 | } 9 | 10 | @tippyMixin 11 | export class CakeDay extends Component { 12 | render() { 13 | return ( 14 |
18 | 19 |
20 | ); 21 | } 22 | 23 | cakeDayTippy(): string { 24 | return I18NextService.i18n.t("cake_day_info", { 25 | creator_name: this.props.creatorName, 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/shared/components/common/anonymous-guard.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "inferno"; 2 | import { Spinner } from "./icon"; 3 | import { isBrowser } from "@utils/browser"; 4 | import { MyUserInfo } from "lemmy-js-client"; 5 | 6 | interface AnonymousGuardProps { 7 | myUserInfo: MyUserInfo | undefined; 8 | } 9 | 10 | class AnonymousGuard extends Component { 11 | constructor(props: any, context: any) { 12 | super(props, context); 13 | } 14 | 15 | hasAuth() { 16 | return this.props.myUserInfo; 17 | } 18 | 19 | componentWillMount() { 20 | if (this.hasAuth() && isBrowser()) { 21 | this.context.router.history.replace(`/`); 22 | } 23 | } 24 | 25 | render() { 26 | return !this.hasAuth() ? this.props.children : ; 27 | } 28 | } 29 | 30 | export default AnonymousGuard; 31 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.vaporwave.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | // Colors 4 | $gray-100: #f8f9fa; 5 | $gray-300: #dee2e6; 6 | $gray-500: #adb5bd; 7 | 8 | $blue: #01cdfe; 9 | $indigo: #b967ff; 10 | $purple: #b967ff; 11 | $pink: rgb(255, 64, 186); 12 | $red: rgb(255, 95, 110); 13 | $orange: rgb(255, 167, 93); 14 | $yellow: #fffb96; 15 | $green: #05ffa1; 16 | $teal: #01cdfe; 17 | $cyan: #01cdfe; 18 | 19 | $primary: $pink; 20 | $secondary: $blue; 21 | 22 | $enable-shadows: true; 23 | $enable-gradients: true; 24 | $enable-responsive-font-sizes: true; 25 | 26 | $border-radius: 1rem; 27 | $border-radius-lg: 1rem; 28 | $font-family-monospace: Arial, "Noto Sans", sans-serif; 29 | $yiq-text-light: $gray-300; 30 | $text-muted: $gray-500; 31 | $navbar-light-hover-color: rgba($primary, 0.7); 32 | $font-family-sans-serif: "Lucida Console", Monaco, monospace; 33 | -------------------------------------------------------------------------------- /src/assets/css/code-themes/atom-one-dark.css: -------------------------------------------------------------------------------- 1 | pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34}.hljs-comment,.hljs-quote{color:#5c6370;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#c678dd}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e06c75}.hljs-literal{color:#56b6c2}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#98c379}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#d19a66}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#61aeee}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#e6c07b}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline} -------------------------------------------------------------------------------- /src/assets/css/code-themes/atom-one-light.css: -------------------------------------------------------------------------------- 1 | pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#383a42;background:#fafafa}.hljs-comment,.hljs-quote{color:#a0a1a7;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#a626a4}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e45649}.hljs-literal{color:#0184bb}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#50a14f}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#986801}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#4078f2}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#c18401}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline} -------------------------------------------------------------------------------- /src/shared/components/common/loading-ellipses.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "inferno"; 2 | 3 | interface LoadingEllipsesState { 4 | ellipses: string; 5 | } 6 | 7 | export class LoadingEllipses extends Component { 8 | state: LoadingEllipsesState = { 9 | ellipses: "...", 10 | }; 11 | #interval?: NodeJS.Timeout; 12 | 13 | constructor(props: any, context: any) { 14 | super(props, context); 15 | } 16 | 17 | render() { 18 | return this.state.ellipses; 19 | } 20 | 21 | componentDidMount() { 22 | this.#interval = setInterval(this.#updateEllipses, 1000); 23 | } 24 | 25 | componentWillUnmount() { 26 | clearInterval(this.#interval); 27 | } 28 | 29 | #updateEllipses = () => { 30 | this.setState(({ ellipses }) => ({ 31 | ellipses: ellipses.length === 3 ? "" : ellipses + ".", 32 | })); 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/shared/components/common/paginator.tsx: -------------------------------------------------------------------------------- 1 | import { I18NextService } from "../../services"; 2 | 3 | interface PaginatorProps { 4 | onNext(): void; 5 | onPrev(): void; 6 | nextDisabled: boolean; 7 | disabled?: boolean; 8 | } 9 | 10 | const Paginator = ({ 11 | nextDisabled, 12 | onNext, 13 | onPrev, 14 | disabled, 15 | }: PaginatorProps) => ( 16 |
17 | 24 | {!nextDisabled && ( 25 | 32 | )} 33 |
34 | ); 35 | 36 | export default Paginator; 37 | -------------------------------------------------------------------------------- /src/shared/components/common/emoji-mart.tsx: -------------------------------------------------------------------------------- 1 | import { Component, RefObject, createRef } from "inferno"; 2 | import { getEmojiMart } from "@utils/markdown"; 3 | 4 | interface EmojiMartProps { 5 | onEmojiClick?(val: any): any; 6 | pickerOptions: any; 7 | } 8 | 9 | export class EmojiMart extends Component { 10 | div: RefObject; 11 | 12 | constructor(props: any, context: any) { 13 | super(props, context); 14 | 15 | this.div = createRef(); 16 | 17 | this.handleEmojiClick = this.handleEmojiClick.bind(this); 18 | } 19 | 20 | componentDidMount() { 21 | this.div.current?.appendChild( 22 | getEmojiMart(this.handleEmojiClick, this.props.pickerOptions) as any, 23 | ); 24 | } 25 | 26 | render() { 27 | return
; 28 | } 29 | 30 | handleEmojiClick(e: any) { 31 | this.props.onEmojiClick?.(e); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/server/utils/build-themes-list.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from "fs"; 2 | import { readdir } from "fs/promises"; 3 | 4 | const extraThemesFolder = 5 | process.env["LEMMY_UI_EXTRA_THEMES_FOLDER"] || "./extra_themes"; 6 | 7 | const themes: ReadonlyArray = [ 8 | "darkly", 9 | "darkly-red", 10 | "darkly-compact", 11 | "darkly-pureblack", 12 | "litely", 13 | "litely-red", 14 | "litely-compact", 15 | "vaporwave-dark", 16 | "vaporwave-light", 17 | "i386-dark", 18 | "RBlind-Dark", 19 | "RBlind-Light", 20 | ]; 21 | 22 | export async function buildThemeList(): Promise> { 23 | if (existsSync(extraThemesFolder)) { 24 | const dirThemes = await readdir(extraThemesFolder); 25 | const cssThemes = dirThemes 26 | .filter(d => d.endsWith(".css")) 27 | .map(d => d.replace(".css", "")); 28 | return themes.concat(cssThemes); 29 | } 30 | return themes; 31 | } 32 | -------------------------------------------------------------------------------- /src/embedded/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error has a weird import error 2 | import { lazyLoad } from "unlazy"; 3 | 4 | // WARNING: This script is written as content into a script tag. A closing 5 | // tag, even in an import, can break things. 6 | 7 | // document.body doesn't exist yet 8 | window.requestAnimationFrame(() => { 9 | const cleanup = lazyLoad('img[loading="lazy"]'); 10 | // The timeout gives enough time to display the blurred image and to start 11 | // loading the images, the Pictrs component creates a new lazyLoad instance 12 | // that will handle the actual display of loaded image. 13 | setTimeout(() => { 14 | cleanup(); 15 | }); 16 | }); 17 | 18 | if (!document.documentElement.hasAttribute("data-bs-theme")) { 19 | const light = window.matchMedia("(prefers-color-scheme: light)").matches; 20 | document.documentElement.setAttribute( 21 | "data-bs-theme", 22 | light ? "light" : "dark", 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/server/handlers/manifest-handler.ts: -------------------------------------------------------------------------------- 1 | import { getHttpBaseInternal } from "@utils/env"; 2 | import type { Request, Response } from "express"; 3 | import { LemmyHttp } from "lemmy-js-client"; 4 | import { wrapClient } from "../../shared/services/HttpService"; 5 | import generateManifestJson from "../utils/generate-manifest-json"; 6 | 7 | let manifest: Awaited> | undefined = 8 | undefined; 9 | 10 | export default async (_req: Request, res: Response) => { 11 | if (!manifest) { 12 | const client = wrapClient(new LemmyHttp(getHttpBaseInternal())); 13 | const site = await client.getSite(); 14 | 15 | if (site.state === "success") { 16 | manifest = await generateManifestJson(site.data.site_view.site); 17 | } else { 18 | res.sendStatus(500); 19 | return; 20 | } 21 | } 22 | 23 | res.setHeader("content-type", "application/manifest+json"); 24 | 25 | res.send(manifest); 26 | }; 27 | -------------------------------------------------------------------------------- /src/shared/components/common/error-guard.tsx: -------------------------------------------------------------------------------- 1 | import { setIsoData } from "@utils/app"; 2 | import { Component } from "inferno"; 3 | import { ErrorPage } from "../app/error-page"; 4 | 5 | class ErrorGuard extends Component { 6 | private isoData = setIsoData(this.context); 7 | 8 | constructor(props: any, context: any) { 9 | super(props, context); 10 | } 11 | 12 | componentWillUnmount(): void { 13 | const { errorPageData, siteRes } = this.isoData; 14 | if (errorPageData || !siteRes) { 15 | // Without reload the error data is still present at the new route 16 | window.location.reload(); 17 | } 18 | } 19 | 20 | render() { 21 | const errorPageData = this.isoData.errorPageData; 22 | const siteRes = this.isoData.siteRes; 23 | 24 | if (errorPageData || !siteRes) { 25 | return ; 26 | } else { 27 | return this.props.children; 28 | } 29 | } 30 | } 31 | 32 | export default ErrorGuard; 33 | -------------------------------------------------------------------------------- /src/shared/components/common/banner-icon-header.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "inferno"; 2 | import { PictrsImage } from "./pictrs-image"; 3 | 4 | interface BannerIconHeaderProps { 5 | banner?: string; 6 | icon?: string; 7 | } 8 | 9 | export class BannerIconHeader extends Component { 10 | constructor(props: any, context: any) { 11 | super(props, context); 12 | } 13 | 14 | render() { 15 | const banner = this.props.banner; 16 | const icon = this.props.icon; 17 | return ( 18 | (banner || icon) && ( 19 |
20 | {banner && } 21 | {icon && ( 22 | 28 | )} 29 |
30 | ) 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/server/utils/dev-env.ts: -------------------------------------------------------------------------------- 1 | /** Returns the default when the environment variable is either undefined or 2 | * the empty string, considers every value other than "true" as `false`. */ 3 | function envBoolean( 4 | envVar: string, 5 | default_: boolean = process.env.NODE_ENV === "development", 6 | ) { 7 | const envVal = process.env[envVar]; 8 | if (envVal) { 9 | return envVal === "true"; 10 | } 11 | return default_; 12 | } 13 | 14 | /** Defaults to true in development, false in production. */ 15 | export const enableEruda = envBoolean("LEMMY_UI_ERUDA"); 16 | 17 | /** Disabled by default, as `.css.map` files are untracked and only exist 18 | * after running `pnpm themes:build`. They also become possibly outdated after 19 | * switching between commits. 20 | */ 21 | export const serveCssMaps = envBoolean("LEMMY_UI_SERVE_CSS_MAPS", false); 22 | 23 | /** Defaults to true in development, false in production. */ 24 | export const enableResponseBodyCompression = envBoolean("LEMMY_UI_COMPRESSION"); 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "pretty": true, 4 | "target": "esnext", 5 | "module": "esnext", 6 | "allowSyntheticDefaultImports": true, 7 | "preserveConstEnums": true, 8 | "sourceMap": true, 9 | "moduleResolution": "node", 10 | "lib": ["es2022", "dom"], 11 | "types": ["inferno"], 12 | "jsx": "preserve", 13 | "noUnusedLocals": true, 14 | "baseUrl": "./src", 15 | "noEmit": true, 16 | "skipLibCheck": true, 17 | "noUnusedParameters": true, 18 | "noImplicitReturns": true, 19 | "experimentalDecorators": true, // false for non-legacy decorators 20 | "strictNullChecks": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "paths": { 23 | "@/*": ["/*"], 24 | "@utils/*": ["shared/utils/*"], 25 | "@services/*": ["shared/services/*"], 26 | "@components/*": ["shared/components/*"] 27 | } 28 | }, 29 | "include": [ 30 | "src/shared/build-config.d.ts", 31 | "src/**/*.ts", 32 | "src/**/*.tsx", 33 | "node_modules/inferno/dist/index.d.ts" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /src/shared/components/common/language-list.tsx: -------------------------------------------------------------------------------- 1 | import { I18NextService } from "@services/index"; 2 | import { Language } from "lemmy-js-client"; 3 | 4 | interface LanguageListProps { 5 | allLanguages?: Language[]; 6 | languageIds?: number[]; 7 | } 8 | 9 | export function LanguageList({ allLanguages, languageIds }: LanguageListProps) { 10 | const langs = allLanguages?.filter(x => languageIds?.includes(x.id)); 11 | 12 | const showLanguages = 13 | allLanguages && langs && langs.length < allLanguages.length; 14 | 15 | return ( 16 | showLanguages && ( 17 |
18 |
    19 | {langs.map(l => ( 20 |
  • 21 | {languageName(l)} 22 |
  • 23 | ))} 24 |
25 |
26 | ) 27 | ); 28 | } 29 | 30 | export function languageName(l: Language): string { 31 | if (l.id === 0) { 32 | return I18NextService.i18n.t("unknown_language"); 33 | } else { 34 | return l.name; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/server/handlers/code-theme-handler.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import { existsSync } from "fs"; 3 | import path from "path"; 4 | 5 | const extraThemesFolder = 6 | process.env["LEMMY_UI_EXTRA_THEMES_FOLDER"] || "./extra_themes"; 7 | 8 | export default async (req: Request, res: Response) => { 9 | res.contentType("text/css"); 10 | 11 | const theme = req.params.name; 12 | 13 | if (!theme.endsWith(".css")) { 14 | res.status(400).send("Theme must be a css file"); 15 | return; 16 | } 17 | 18 | const customTheme = path.resolve(extraThemesFolder, theme); 19 | 20 | if (existsSync(customTheme)) { 21 | res.sendFile(customTheme); 22 | } else { 23 | const internalTheme = path.resolve( 24 | `./dist/assets/css/code-themes/${theme}`, 25 | ); 26 | 27 | // If the theme doesn't exist, just send atom-one-light 28 | if (existsSync(internalTheme)) { 29 | res.sendFile(internalTheme); 30 | } else { 31 | res.sendFile( 32 | path.resolve("./dist/assets/css/code-themes/atom-one-light.css"), 33 | ); 34 | } 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /src/shared/components/common/auth-guard.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "inferno"; 2 | import { RouteComponentProps } from "inferno-router/dist/Route"; 3 | import { Spinner } from "./icon"; 4 | import { getQueryString } from "@utils/helpers"; 5 | import { isBrowser } from "@utils/browser"; 6 | import { MyUserInfo } from "lemmy-js-client"; 7 | 8 | interface AuthGuardProps extends RouteComponentProps> { 9 | myUserInfo: MyUserInfo | undefined; 10 | } 11 | 12 | export default class AuthGuard extends Component { 13 | constructor(props: AuthGuardProps, context: any) { 14 | super(props, context); 15 | } 16 | 17 | hasAuth() { 18 | return this.props.myUserInfo; 19 | } 20 | 21 | componentWillMount() { 22 | if (!this.hasAuth() && isBrowser()) { 23 | const { pathname, search } = this.props.location; 24 | this.context.router.history.replace( 25 | `/login${getQueryString({ prev: pathname + search })}`, 26 | ); 27 | } 28 | } 29 | 30 | render() { 31 | return this.hasAuth() ? this.props.children : ; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/shared/build-config.js: -------------------------------------------------------------------------------- 1 | // Don't import/require things here. This file is also imported in 2 | // webpack.config.js. Needs dev server restart to apply changes. 3 | 4 | /** Bundled highlighters can be autodetected in markdown. 5 | * @type ["plaintext", ...string[]] **/ 6 | // prettier-ignore 7 | const bundledSyntaxHighlighters = [ 8 | "plaintext", 9 | // The 'Common' set of highlight.js languages. 10 | "bash", "c", "cpp", "csharp", "css", "diff", "go", "graphql", "ini", "java", 11 | "javascript", "json", "kotlin", "less", "lua", "makefile", "markdown", 12 | "objectivec", "perl", "php-template", "php", "python-repl", "python", "r", 13 | "ruby", "rust", "scss", "shell", "sql", "swift", "typescript", "vbnet", 14 | "wasm", "xml", "yaml", 15 | ]; 16 | 17 | /** Lazy highlighters can't be autodetected, they have to be explicitly specified 18 | * as the language. (e.g. ```dockerfile ...) 19 | * "*" enables all non-bundled languages 20 | * @type string[] | "*" **/ 21 | const lazySyntaxHighlighters = "*"; 22 | 23 | module.exports = { 24 | bundledSyntaxHighlighters, 25 | lazySyntaxHighlighters, 26 | }; 27 | -------------------------------------------------------------------------------- /src/shared/components/home/legal.tsx: -------------------------------------------------------------------------------- 1 | import { setIsoData } from "@utils/app"; 2 | import { Component } from "inferno"; 3 | import { mdToHtml } from "@utils/markdown"; 4 | import { I18NextService } from "../../services"; 5 | import { HtmlTags } from "../common/html-tags"; 6 | 7 | export class Legal extends Component { 8 | private isoData = setIsoData(this.context); 9 | 10 | constructor(props: any, context: any) { 11 | super(props, context); 12 | } 13 | 14 | get documentTitle(): string { 15 | return I18NextService.i18n.t("legal_information"); 16 | } 17 | 18 | render() { 19 | const legal = this.isoData.siteRes?.site_view.local_site.legal_information; 20 | return ( 21 |
22 | 26 | {legal && ( 27 |
this.forceUpdate())} 30 | /> 31 | )} 32 |
33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/assets/css/themes/darkly-compact.scss: -------------------------------------------------------------------------------- 1 | @import "variables.darkly-compact"; 2 | 3 | /* 4 | GENERAL 5 | */ 6 | 7 | // Desktop Breakpoint 8 | $container-max-widths: ( 9 | lg: 1920px, 10 | ); 11 | 12 | // Reduce hr height 13 | hr.my-3 { 14 | margin-top: 0.5rem !important; 15 | margin-bottom: 0.5rem !important; 16 | } 17 | 18 | /* 19 | POST-LISTING 20 | */ 21 | 22 | .post-listing { 23 | line-height: 1.25; 24 | 25 | .post-title h5 { 26 | margin: 0; 27 | } 28 | 29 | .post-title + p { 30 | padding-top: 0.125rem !important; 31 | padding-bottom: 0.125rem !important; 32 | } 33 | 34 | .community-link { 35 | padding-left: 0.125rem; 36 | } 37 | 38 | .person-listing { 39 | padding-right: 0.125rem; 40 | } 41 | 42 | ul.list-inline { 43 | &.mt-2 { 44 | margin-top: 0.125rem !important; 45 | } 46 | &.mb-1 { 47 | margin-bottom: 0.125rem !important; 48 | } 49 | } 50 | 51 | .btn-sm { 52 | --bs-btn-padding-y: 0; 53 | } 54 | .img-icon { 55 | display: none; 56 | } 57 | } 58 | 59 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 60 | -------------------------------------------------------------------------------- /src/assets/css/themes/litely-compact.scss: -------------------------------------------------------------------------------- 1 | @import "variables.litely-compact"; 2 | 3 | /* 4 | GENERAL 5 | */ 6 | 7 | // Desktop Breakpoint 8 | $container-max-widths: ( 9 | lg: 1920px, 10 | ); 11 | 12 | // Reduce hr height 13 | hr.my-3 { 14 | margin-top: 0.5rem !important; 15 | margin-bottom: 0.5rem !important; 16 | } 17 | 18 | /* 19 | POST-LISTING 20 | */ 21 | 22 | .post-listing { 23 | line-height: 1.25; 24 | 25 | .post-title h5 { 26 | margin: 0; 27 | } 28 | 29 | .post-title + p { 30 | padding-top: 0.125rem !important; 31 | padding-bottom: 0.125rem !important; 32 | } 33 | 34 | .community-link { 35 | padding-left: 0.125rem; 36 | } 37 | 38 | .person-listing { 39 | padding-right: 0.125rem; 40 | } 41 | 42 | ul.list-inline { 43 | &.mt-2 { 44 | margin-top: 0.125rem !important; 45 | } 46 | &.mb-1 { 47 | margin-bottom: 0.125rem !important; 48 | } 49 | } 50 | 51 | .btn-sm { 52 | --bs-btn-padding-y: 0; 53 | } 54 | .img-icon { 55 | display: none; 56 | } 57 | } 58 | 59 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 60 | -------------------------------------------------------------------------------- /src/shared/utils/media.ts: -------------------------------------------------------------------------------- 1 | const imageRegex = /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg|webp))/; 2 | 3 | export function isImage(url: string) { 4 | return imageRegex.test(url); 5 | } 6 | 7 | const animatedImageRegex = /(http)?s?:?(\/\/[^"']*\.(?:gif))/; 8 | 9 | export function isAnimatedImage(url: string) { 10 | return animatedImageRegex.test(url); 11 | } 12 | 13 | const videoRegex = /(http)?s?:?(\/\/[^"']*\.(?:mp4|webm|ogv))/; 14 | 15 | const audioRegex = /(http)?s?:?(\/\/[^"']*\.(?:mp3|wav|opus|ogg|m4a|flac|spx))/; 16 | 17 | export function isVideo(url: string) { 18 | return videoRegex.test(url); 19 | } 20 | 21 | export function isAudio(url: string) { 22 | return audioRegex.test(url); 23 | } 24 | 25 | /** 26 | * Is true if its an image, audio, or video 27 | **/ 28 | export function isMedia(url: string) { 29 | return isImage(url) || isVideo(url) || isAudio(url); 30 | } 31 | 32 | const magnetLinkRegex = /^magnet:\?xt=urn:btih:[0-9a-fA-F]{40,}.*$/; 33 | 34 | export function isMagnetLink(url: string) { 35 | return magnetLinkRegex.test(url); 36 | } 37 | 38 | export function extractMagnetLinkDownloadName(url: string) { 39 | return new URLSearchParams(url).get("dn"); 40 | } 41 | -------------------------------------------------------------------------------- /src/shared/components/common/progress-bar.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeColor } from "@utils/types"; 2 | import classNames from "classnames"; 3 | 4 | interface ProgressBarProps { 5 | className?: string; 6 | backgroundColor?: ThemeColor; 7 | barColor?: ThemeColor; 8 | striped?: boolean; 9 | animated?: boolean; 10 | min?: number; 11 | max?: number; 12 | value: number; 13 | text?: string; 14 | } 15 | 16 | const ProgressBar = ({ 17 | value, 18 | animated = false, 19 | backgroundColor = "secondary", 20 | barColor = "primary", 21 | className, 22 | max = 100, 23 | min = 0, 24 | striped = false, 25 | text, 26 | }: ProgressBarProps) => ( 27 |
28 |
39 | {text} 40 |
41 |
42 | ); 43 | 44 | export default ProgressBar; 45 | -------------------------------------------------------------------------------- /dev.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:24-alpine as builder 2 | RUN apk update && apk add curl python3 build-base gcc wget git --no-cache 3 | 4 | # Install corepack 5 | RUN npm install -g corepack 6 | 7 | RUN corepack enable pnpm 8 | 9 | WORKDIR /usr/src/app 10 | 11 | ENV npm_config_target_arch=x64 12 | ENV npm_config_target_platform=linux 13 | ENV npm_config_target_libc=musl 14 | 15 | # Cache deps 16 | COPY package.json pnpm-lock.yaml ./ 17 | RUN pnpm i --prefer-offline 18 | 19 | # Build 20 | COPY generate_translations.js \ 21 | tsconfig.json \ 22 | webpack.config.js \ 23 | .babelrc \ 24 | ./ 25 | 26 | COPY lemmy-translations lemmy-translations 27 | COPY src src 28 | COPY .git .git 29 | 30 | # Set UI version 31 | RUN echo "export const VERSION = 'dev';" > "src/shared/version.ts" 32 | RUN echo "export const BUILD_DATE_ISO8601 = '$(date -u +"%Y-%m-%dT%H:%M:%SZ")';" > "src/shared/build-date.ts" 33 | 34 | RUN pnpm i --prefer-offline 35 | RUN pnpm build:dev 36 | 37 | FROM node:24-alpine as runner 38 | COPY --from=builder /usr/src/app/dist /app/dist 39 | COPY --from=builder /usr/src/app/node_modules /app/node_modules 40 | 41 | EXPOSE 1234 42 | WORKDIR /app 43 | CMD node --enable-source-maps dist/js/server.js 44 | -------------------------------------------------------------------------------- /src/shared/components/home/federation-mode-select.tsx: -------------------------------------------------------------------------------- 1 | import { LinkedEvent, FormEvent, Component } from "inferno"; 2 | import { FederationMode } from "lemmy-js-client"; 3 | import { I18NextService } from "../../services"; 4 | import { NoOptionI18nKeys } from "i18next"; 5 | 6 | interface FederationModeSelectProps { 7 | id: string; 8 | current: FederationMode; 9 | onChange: LinkedEvent> | null; 10 | } 11 | 12 | const modes: { value: FederationMode; i18nKey: NoOptionI18nKeys }[] = [ 13 | { value: "all", i18nKey: "all" }, 14 | { value: "local", i18nKey: "local" }, 15 | { value: "disable", i18nKey: "disable" }, 16 | ]; 17 | 18 | export class FederationModeSelect extends Component< 19 | FederationModeSelectProps 20 | > { 21 | render() { 22 | return ( 23 | <> 24 | 36 | 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.litely.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | // Colors 4 | $gray-100: #f8f9fa; 5 | $gray-600: #6c757d; 6 | $gray-700: #495057; 7 | $gray-800: #343a40; 8 | $gray-900: #212529; 9 | $black: #222; 10 | 11 | $blue: #007bff; 12 | $indigo: #6610f2; 13 | $red: #d8486a; 14 | $orange: #f1641e; 15 | $green: #00a846; 16 | $cyan: #02bdc2; 17 | 18 | $primary: $blue; 19 | $secondary: $gray-600; 20 | $success: $green; 21 | $info: $orange; 22 | $danger: $red; 23 | 24 | $body-color: $gray-700; 25 | $body-bg: #fff; 26 | $border-color: rgba($body-color, 0.25); 27 | $mark-bg: rgb(255, 252, 239); 28 | $headings-color: $gray-700; 29 | 30 | $font-family-sans-serif: 31 | -apple-system, BlinkMacSystemFont, "Droid Sans", "Segoe UI", Verdana, "Arimo", 32 | "Helvetica", Arial, sans-serif; 33 | $font-weight-bold: 600; 34 | 35 | $navbar-dark-toggler-border-color: rgba($black, 0.1); 36 | $navbar-light-color: $gray-600; 37 | $navbar-light-hover-color: $gray-900; 38 | $navbar-light-active-color: $gray-900; 39 | 40 | $form-feedback-valid-color: $info; 41 | $input-btn-focus-color: rgba($primary, 0.75); 42 | 43 | $border-radius: 0.5rem; 44 | $border-radius-lg: 0.5rem; 45 | $border-radius-sm: 1rem; 46 | $rounded-pill: 0.25rem; 47 | 48 | $hr-border-color: rgba($body-color, 0.25); 49 | -------------------------------------------------------------------------------- /src/server/handlers/theme-handler.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import { existsSync } from "fs"; 3 | import path from "path"; 4 | import { serveCssMaps } from "../utils/dev-env"; 5 | 6 | const extraThemesFolder = 7 | process.env["LEMMY_UI_EXTRA_THEMES_FOLDER"] || "./extra_themes"; 8 | 9 | export default async (req: Request, res: Response) => { 10 | const theme = req.params.name; 11 | 12 | if (theme.endsWith(".css")) { 13 | res.contentType("text/css"); 14 | } else if (theme.endsWith(".css.map")) { 15 | res.contentType("application/json"); 16 | } 17 | 18 | if ( 19 | !theme.endsWith(".css") && 20 | !(serveCssMaps && theme.endsWith(".css.map")) 21 | ) { 22 | res.status(400).send("Theme must be a css file"); 23 | return; 24 | } 25 | 26 | const customTheme = path.resolve(extraThemesFolder, theme); 27 | 28 | if (existsSync(customTheme)) { 29 | res.sendFile(customTheme); 30 | } else { 31 | const internalTheme = path.resolve(`./dist/assets/css/themes/${theme}`); 32 | 33 | // If the theme doesn't exist, just send litely 34 | if (existsSync(internalTheme)) { 35 | res.sendFile(internalTheme); 36 | } else { 37 | res.sendFile(path.resolve("./dist/assets/css/themes/litely.css")); 38 | } 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F680 Feature request" 2 | description: Suggest an idea for Lemmy-UI. 3 | labels: ["enhancement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to help improve Lemmy-UI by suggesting a feature! 9 | - type: checkboxes 10 | attributes: 11 | label: Requirements 12 | description: Before you create a feature request, please carefully check the following – 13 | options: 14 | - label: This is a feature request and not a bug report. Otherwise, please create a new [bug report](https://github.com/LemmyNet/lemmy-ui/issues/new?assignees=&labels=bug%2Ctriage&projects=&template=BUG_REPORT.yml) instead. 15 | required: true 16 | - label: Please [check](https://github.com/LemmyNet/lemmy-ui/issues) to see if this request (or a similar one) already exists. 17 | required: true 18 | - label: It's a single feature. Please don't request multiple features in one issue. 19 | required: true 20 | - type: textarea 21 | id: solution 22 | attributes: 23 | label: Describe the feature you'd like 24 | description: | 25 | Provide a clear and concise description of the feature. Explain why it's needed. 26 | validations: 27 | required: true 28 | -------------------------------------------------------------------------------- /src/shared/components/app/code-theme.tsx: -------------------------------------------------------------------------------- 1 | import { dataBsTheme } from "@utils/browser"; 2 | import { Component } from "inferno"; 3 | import { Helmet } from "inferno-helmet"; 4 | 5 | interface CodeThemeProps { 6 | theme: string; 7 | } 8 | 9 | export class CodeTheme extends Component { 10 | render() { 11 | const { theme } = this.props; 12 | const hasTheme = theme !== "browser" && theme !== "browser-compact"; 13 | 14 | if (!hasTheme) { 15 | return ( 16 | 17 | 23 | 29 | 30 | ); 31 | } 32 | 33 | return ( 34 | 35 | {dataBsTheme(theme) === "dark" ? ( 36 | 41 | ) : ( 42 | 47 | )} 48 | 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.i386-dark.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | // Colors 4 | $white: #fff; 5 | $gray-100: #f8f9fa; 6 | $gray-200: #ebebeb; 7 | $gray-300: #bbb; 8 | $gray-500: #adb5bd; 9 | $gray-800: #303030; 10 | $gray-900: #222; 11 | 12 | $blue: #5555ff; 13 | $cyan: #55ffff; 14 | $green: #55ff55; 15 | $indigo: #ff55ff; 16 | $red: #ff5555; 17 | $yellow: #fefe54; 18 | $orange: #a85400; 19 | $pink: #fe54fe; 20 | $purple: #fe5454; 21 | 22 | $primary: #fefe54; 23 | $secondary: $gray-900; 24 | $success: #00aa00; 25 | $danger: #aa0000; 26 | $info: #00aaaa; 27 | $warning: #aa00aa; 28 | $light: $gray-800; 29 | $dark: $gray-300; 30 | 31 | $body-bg: #000084; 32 | $body-color: $gray-300; 33 | 34 | $link-hover-color: $white; 35 | 36 | $font-family-sans-serif: DOS, Monaco, Menlo, Consolas, "Courier New", monospace; 37 | $font-family-monospace: DOS, Monaco, Menlo, Consolas, "Courier New", monospace; 38 | 39 | $navbar-dark-color: $gray-300; 40 | $navbar-light-brand-color: $gray-300; 41 | $navbar-dark-active-color: $gray-100; 42 | $nav-tabs-link-active-color: $gray-100; 43 | $navbar-dark-hover-color: rgba($gray-300, 0.75); 44 | $navbar-light-disabled-color: $gray-800; 45 | $navbar-light-active-color: $gray-100; 46 | $navbar-light-hover-color: $gray-200; 47 | $navbar-light-color: $gray-300; 48 | 49 | $enable-rounded: false; 50 | 51 | $input-color: $white; 52 | $input-bg: rgb(102, 102, 102); 53 | $input-placeholder-color: $gray-500; 54 | $input-disabled-bg: $gray-800; 55 | 56 | $card-bg: $gray-800; 57 | $card-border-color: $white; 58 | $mark-bg: #463b00; 59 | $mark-bg-dark: #463b00; 60 | -------------------------------------------------------------------------------- /src/shared/components/common/radio-button-group.tsx: -------------------------------------------------------------------------------- 1 | import { I18NextService } from "@services/index"; 2 | import { randomStr } from "@utils/helpers"; 3 | import classNames from "classnames"; 4 | import { NoOptionI18nKeys } from "i18next"; 5 | 6 | export interface RadioOption { 7 | value: string; 8 | i18n?: NoOptionI18nKeys; 9 | } 10 | 11 | export interface RadioButtonGroupProps { 12 | className?: string; 13 | allOptions: RadioOption[]; 14 | currentOption: string; 15 | onClick(val: string): void; 16 | } 17 | 18 | export function RadioButtonGroup(props: RadioButtonGroupProps) { 19 | const radioId = randomStr(); 20 | return ( 21 |
28 | {props.allOptions.map(state => ( 29 | <> 30 | props.onClick(state.value)} 37 | /> 38 | 48 | 49 | ))} 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/client/index.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from "inferno-hydrate"; 2 | import { BrowserRouter } from "inferno-router"; 3 | import App from "../shared/components/app/app"; 4 | import { lazyHighlightjs } from "@utils/lazy-highlightjs"; 5 | import { loadLanguageInstances } from "@services/I18NextService"; 6 | import { verifyDynamicImports } from "@utils/dynamic-imports"; 7 | import { setupMarkdown } from "@utils/markdown"; 8 | 9 | import "bootstrap/js/dist/collapse"; 10 | import "bootstrap/js/dist/dropdown"; 11 | import "bootstrap/js/dist/modal"; 12 | 13 | async function startClient() { 14 | // Allows to test imports from the browser console. 15 | window.checkLazyScripts = () => { 16 | verifyDynamicImports(true).then(x => console.debug(x)); 17 | }; 18 | 19 | window.history.scrollRestoration = "manual"; 20 | 21 | setupMarkdown(); 22 | lazyHighlightjs.enableLazyLoading(); 23 | 24 | const fallbackLanguages = window.navigator.languages; 25 | const interfaceLanguage = 26 | window.isoData?.myUserInfo?.local_user_view.local_user.interface_language; 27 | 28 | // Make sure the language is loaded before hydration. 29 | const [[dateFnsLocale, i18n]] = await Promise.all([ 30 | loadLanguageInstances(fallbackLanguages, interfaceLanguage), 31 | ]); 32 | 33 | const wrapper = ( 34 | 35 | 36 | 37 | ); 38 | 39 | const root = document.getElementById("root"); 40 | 41 | if (root) { 42 | hydrate(wrapper, root); 43 | 44 | root.dispatchEvent(new CustomEvent("lemmy-hydrated", { bubbles: true })); 45 | } 46 | } 47 | 48 | startClient(); 49 | -------------------------------------------------------------------------------- /src/shared/components/common/post-listing-mode-select.tsx: -------------------------------------------------------------------------------- 1 | import { randomStr } from "@utils/helpers"; 2 | import { Component, linkEvent } from "inferno"; 3 | import { PostListingMode } from "lemmy-js-client"; 4 | import { I18NextService } from "../../services"; 5 | import { NoOptionI18nKeys } from "i18next"; 6 | 7 | interface Props { 8 | current: PostListingMode; 9 | onChange(val: PostListingMode): void; 10 | } 11 | 12 | type Choice = { 13 | key: NoOptionI18nKeys; 14 | value: PostListingMode; 15 | }; 16 | 17 | const choices: Choice[] = [ 18 | { key: "list", value: "list" }, 19 | { key: "card", value: "card" }, 20 | { key: "small_card", value: "small_card" }, 21 | ]; 22 | 23 | export class PostListingModeSelect extends Component { 24 | private id = `post-listing-mode-select-${randomStr()}`; 25 | 26 | constructor(props: any, context: any) { 27 | super(props, context); 28 | } 29 | 30 | render() { 31 | return ( 32 | 51 | ); 52 | } 53 | 54 | handleChange(i: PostListingModeSelect, event: any) { 55 | i.props.onChange(event.target.value); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/shared/utils/env.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from "@utils/browser"; 2 | import { testHost } from "@utils/config"; 3 | import { IsoDataOptionalSite } from "@utils/types"; 4 | 5 | // The browser uses the hosts in IsoData. 6 | const hosts = !isBrowser() && { 7 | backendInternal: getBaseUrl( 8 | process.env.LEMMY_UI_BACKEND_INTERNAL ?? 9 | process.env.LEMMY_UI_BACKEND ?? 10 | testHost, 11 | ), 12 | backendExternal: getBaseUrl(process.env.LEMMY_UI_BACKEND ?? testHost), 13 | }; 14 | 15 | export function getHttpBaseInternal() { 16 | if (!hosts) { 17 | throw new Error("Can't access internal backend host"); 18 | } 19 | return hosts.backendInternal; 20 | } 21 | 22 | export function getBackendHostExternal() { 23 | return !hosts ? window.isoData.lemmyBackend : hosts.backendExternal; 24 | } 25 | 26 | export function getBaseUrl(host: string) { 27 | const hasProtocol = /^https?:\/\//.test(host); 28 | const defaultProtocol = 29 | process.env.LEMMY_UI_HTTPS === "true" ? "https:" : "http:"; 30 | const fullHost = hasProtocol ? host : defaultProtocol + "//" + host; 31 | const url = new URL(fullHost); 32 | const { protocol, hostname, port } = url; 33 | return `${protocol}//${hostname}${port ? ":" + port : ""}`; 34 | } 35 | 36 | /** 37 | * Returns path to static directory, intended 38 | * for cache-busting based on latest commit hash. 39 | */ 40 | export function getStaticDir() { 41 | return `/static/${process.env.COMMIT_HASH}`; 42 | } 43 | 44 | export function httpFrontendUrl(path: string, isoData: IsoDataOptionalSite) { 45 | return `${isoData.lemmyFrontend}${path}`; 46 | } 47 | 48 | export function httpBackendUrl(path: string) { 49 | return `${getBackendHostExternal()}${path}`; 50 | } 51 | 52 | export function isHttps() { 53 | return window.location.protocol === "https:"; 54 | } 55 | -------------------------------------------------------------------------------- /src/shared/components/common/blocking-keywords-textarea.tsx: -------------------------------------------------------------------------------- 1 | import { linkEvent, Component } from "inferno"; 2 | import { I18NextService } from "../../services/I18NextService"; 3 | 4 | interface Props { 5 | keywords: string[]; 6 | onUpdate(keywords: string[]): void; 7 | } 8 | 9 | interface State { 10 | text: string; 11 | } 12 | 13 | function handleTextChange(i: BlockingKeywordsTextArea, event: any) { 14 | i.setState({ text: event.target.value }); 15 | } 16 | 17 | function handleTextBlur(i: BlockingKeywordsTextArea, _event: any) { 18 | const keywords = fromText(i.state.text); 19 | i.props.onUpdate(keywords); 20 | } 21 | 22 | function toText(keywords: string[]): string { 23 | return keywords.join("\n"); 24 | } 25 | 26 | function fromText(text: string): string[] { 27 | // Split lines 28 | const intermediateText = text.replace(/\s+/g, "\n"); 29 | 30 | // Split by newlines, and filter out empty strings 31 | // Note: an empty array is a Some(None) / erase 32 | const keywords = intermediateText.split("\n").filter(k => k.trim()); 33 | return keywords; 34 | } 35 | 36 | export default class BlockingKeywordsTextArea extends Component { 37 | state: State = { 38 | text: toText(this.props.keywords), 39 | }; 40 | 41 | render() { 42 | return ( 43 |
44 | 47 |
48 |