Merge remote-tracking branch 'soapbox/develop' into mastodon-groups
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
commit
bd247797bb
|
@ -0,0 +1 @@
|
|||
CHANGELOG.md merge=union
|
|
@ -25,6 +25,7 @@ deps:
|
|||
cache:
|
||||
<<: *cache
|
||||
policy: push
|
||||
interruptible: true
|
||||
|
||||
danger:
|
||||
stage: test
|
||||
|
@ -33,6 +34,7 @@ danger:
|
|||
- export CI_MERGE_REQUEST_IID=${CI_OPEN_MERGE_REQUESTS#*!}
|
||||
- npx danger ci
|
||||
allow_failure: true
|
||||
interruptible: true
|
||||
|
||||
lint-js:
|
||||
stage: test
|
||||
|
@ -45,6 +47,7 @@ lint-js:
|
|||
- "**/*.tsx"
|
||||
- ".eslintignore"
|
||||
- ".eslintrc.js"
|
||||
interruptible: true
|
||||
|
||||
lint-sass:
|
||||
stage: test
|
||||
|
@ -54,6 +57,7 @@ lint-sass:
|
|||
- "**/*.scss"
|
||||
- "**/*.css"
|
||||
- ".stylelintrc.json"
|
||||
interruptible: true
|
||||
|
||||
jest:
|
||||
stage: test
|
||||
|
@ -76,6 +80,7 @@ jest:
|
|||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: .coverage/cobertura-coverage.xml
|
||||
interruptible: true
|
||||
|
||||
nginx-test:
|
||||
stage: test
|
||||
|
@ -85,6 +90,7 @@ nginx-test:
|
|||
only:
|
||||
changes:
|
||||
- "installation/mastodon.conf"
|
||||
interruptible: true
|
||||
|
||||
build-production:
|
||||
stage: test
|
||||
|
@ -94,6 +100,7 @@ build-production:
|
|||
artifacts:
|
||||
paths:
|
||||
- static
|
||||
interruptible: true
|
||||
|
||||
docs-deploy:
|
||||
stage: deploy
|
||||
|
@ -107,6 +114,7 @@ docs-deploy:
|
|||
- develop
|
||||
changes:
|
||||
- "docs/**/*"
|
||||
interruptible: true
|
||||
|
||||
# Supposed to fail when translations are outdated, instead always passes
|
||||
#
|
||||
|
@ -127,6 +135,7 @@ review:
|
|||
script:
|
||||
- npx -y surge static $CI_COMMIT_REF_SLUG.git.soapbox.pub
|
||||
allow_failure: true
|
||||
interruptible: true
|
||||
|
||||
pages:
|
||||
stage: deploy
|
||||
|
@ -142,6 +151,7 @@ pages:
|
|||
only:
|
||||
refs:
|
||||
- develop
|
||||
interruptible: true
|
||||
|
||||
docker:
|
||||
stage: deploy
|
||||
|
@ -157,4 +167,5 @@ docker:
|
|||
- docker push $CI_REGISTRY_IMAGE
|
||||
only:
|
||||
refs:
|
||||
- develop
|
||||
- develop
|
||||
interruptible: true
|
81
CHANGELOG.md
81
CHANGELOG.md
|
@ -4,6 +4,87 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- Editing: ability to edit posts and view edit history (on Rebased, Pleroma, and Mastodon).
|
||||
- Events: ability to create, view, and comment on Events (on Rebased).
|
||||
- Onboarding: display an introduction wizard to newly registered accounts.
|
||||
- Posts: translate foreign language posts into your native language (on Rebased, Mastodon; if configured by the admin).
|
||||
- Posts: ability to view quotes of a post (on Rebased).
|
||||
- Posts: hover the "replying to" line to see a preview card of the parent post.
|
||||
- Chats: ability to leave a chat (on Rebased, Truth Social).
|
||||
- Chats: ability to disable chats for yourself.
|
||||
- Layout: added right-to-left support for Arabic, Hebrew, Persian, and Central Kurdish languages.
|
||||
- Composer: support custom emoji categories.
|
||||
- Search: ability to search posts from a specific account (on Pleroma, Rebased).
|
||||
- Theme: auto-detect system theme by default.
|
||||
- Profile: remove a specific user from your followers (on Rebased, Mastodon).
|
||||
- Suggestions: ability to view all suggested profiles.
|
||||
- Compatibility: added compatibility with Truth Social, Fedibird, Pixelfed, Akkoma, and Glitch.
|
||||
- Developers: added Test feed, Service Worker debugger, and Network Error preview.
|
||||
- Reports: display server rules in reports. Let users select rule violations when submitting a report.
|
||||
- Admin: added Theme Editor, a GUI for customizing the color scheme.
|
||||
- Admin: custom badges. Admins can add non-federating badges to any user's profile (on Rebased, Pleroma).
|
||||
- Admin: consolidated user dropdown actions (verify/suggest/etc) into a unified "Moderate User" modal.
|
||||
- i18n: updated translations for Italian, Polish, Arabic, Hebrew, and German.
|
||||
|
||||
### Changed
|
||||
- UI: the whole UI has been overhauled both inside and out. 97% of the codebase has been rewritten to TypeScript, and a new component library has been introduced with Tailwind CSS.
|
||||
- Chats: redesigned chats. Includes an improved desktop UI, unified chat widget, expanding textarea, and autosuggestions.
|
||||
- Lists: ability to edit and delete a list.
|
||||
- Settings: unified settings under one path with separate sections.
|
||||
- Posts: changed the thumbs-up icon to a heart.
|
||||
- Posts: move instance favicon beside username instead of post timestamp.
|
||||
- Posts: changed the behavior of content warnings. CWs and sensitive media are unified into one design.
|
||||
- Posts: redesigned interaction counters to use text instead of icons.
|
||||
- Profile: overhauled user profiles to be consistent with the rest of the UI.
|
||||
- Composer: move emoji button alongside other composer buttons, add numerical counter.
|
||||
- Birthdays: move today's birthdays out of notifications into right sidebar.
|
||||
- Performance: improve scrolling/navigation between feeds by using a virtual window library.
|
||||
- Admin: reorganize UI into 3-column layout.
|
||||
- Admin: include external link to frontend repo for the running commit.
|
||||
|
||||
### Removed
|
||||
- Theme: Halloween theme.
|
||||
- Settings: advanced notification settings.
|
||||
- Settings: dyslexic mode.
|
||||
- Settings: demetricator.
|
||||
- Profile: ability to set and view private notes on an account.
|
||||
- Feeds: per-feed filters for replies, media, etc.
|
||||
- Backup and export functionality (for now).
|
||||
- Posts: hide non-emoji images embedded in post content.
|
||||
|
||||
### Security
|
||||
- Glitch Social: fixed XSS vulnerability on Glitch Social where custom emojis could be exploited to embed a script tag.
|
||||
|
||||
## [2.0.0] - 2022-05-01
|
||||
### Added
|
||||
- Quote Posting: repost with comment on Fedibird and Rebased.
|
||||
- Profile: ability to feature other users on your profile (on Rebased, Mastodon).
|
||||
- Profile: ability to add location to the user's profile (on Rebased, Truth Social).
|
||||
- Birthdays: ability to add a birthday to your profile (on Rebased, Pleroma).
|
||||
- Birthdays: support for age-gated registration if configured by the admin (on Rebased, Pleroma).
|
||||
- Birthdays: display today's birthdays in notifications.
|
||||
- Notifications: added unread badge to favicon when user has notifications.
|
||||
- Notifications: display full attachments in notifications instead of links.
|
||||
- Search: added a dedicated search page with prefilled suggestions.
|
||||
- Compatibility: improved support for Mastodon, added support for Mitra.
|
||||
- Ethereum: Metamask sign-in with Mitra.
|
||||
- i18n: added Shavian alphabet (`en-Shaw`) transliteration.
|
||||
- i18n: added Icelandic translation.
|
||||
|
||||
### Changed
|
||||
- Feeds: added gaps between posts in feeds.
|
||||
- Feeds: automatically load new posts when scrolled to the top of the feed.
|
||||
- Layout: improved design of top navigation bar.
|
||||
- Layout: add left sidebar navigation.
|
||||
- Icons: replaced Fork Awesome icons with Tabler icons.
|
||||
- Posts: moved mentions out of the post content into an area above the post for replies (on Pleroma and Rebased - Mastodon falls back to the old behavior).
|
||||
- Composer: use graphical ring counter for character count.
|
||||
|
||||
### Fixed
|
||||
- Multi-Account: fix switching between profiles on different servers with the same local username.
|
||||
|
||||
## [1.3.0] - 2021-07-02
|
||||
### Changed
|
||||
- Layout: show right sidebar on all pages.
|
||||
|
|
212
README.md
212
README.md
|
@ -1,202 +1,80 @@
|
|||
# Soapbox
|
||||
|
||||

|
||||
|
||||
**Soapbox** is a frontend for Mastodon and Pleroma with a focus on custom branding and ease of use.
|
||||
**Soapbox** is customizable open-source software that puts the power of social media in the hands of the people. Feature-rich and hyper-focused on providing a user experience to rival Big Tech, Soapbox is already home to some of the biggest alternative social platforms.
|
||||
|
||||
## Try it out
|
||||
# On The Fediverse
|
||||
|
||||
Visit https://fe.soapbox.pub/ and point it to your favorite instance.
|
||||
You may have heard of **Mastodon**. Soapbox builds upon what Mastodon made great to make something even better.
|
||||
|
||||
## :rocket: Deploy on Pleroma
|
||||
You can run **Mastodon+Soapbox**, **Rebased+Soapbox**, and more.
|
||||
|
||||
Installing Soapbox on an existing Pleroma server is extremely easy.
|
||||
Just ssh into the server and download a .zip of the latest build:
|
||||
Soapbox is the **frontend** (what users see) while Mastodon is the **backend** (data, APIs). You can mix-and-match in the Fediverse ecosystem.
|
||||
|
||||
```sh
|
||||
curl -L https://gitlab.com/soapbox-pub/soapbox/-/jobs/artifacts/develop/download?job=build-production -o soapbox.zip
|
||||
```
|
||||
> 💡 If you're starting a new server, we highly recommend **Rebased+Soapbox**. Rebased is our custom-built backend just for Soapbox, providing important new features such as **quote posting** and **chats**.
|
||||
>
|
||||
> See: [Installing Rebased+Soapbox](https://soapbox.pub/install/)
|
||||
|
||||
Then unpack it into Pleroma's `instance` directory:
|
||||
# Try It Out
|
||||
|
||||
```sh
|
||||
busybox unzip soapbox.zip -o -d /opt/pleroma/instance
|
||||
```
|
||||
Want to give Soapbox a shot? Here are some suggested servers:
|
||||
|
||||
**That's it!** :tada:
|
||||
**Soapbox is installed.**
|
||||
The change will take effect immediately, just refresh your browser tab.
|
||||
It's not necessary to restart the Pleroma service.
|
||||
- [gleasonator.com](https://gleasonator.com/) - operated by the lead developer of Soapbox
|
||||
- [social.teci.world](https://social.teci.world/) - free speech server run by a Soapbox contributor
|
||||
- [spinster.xyz](https://spinster.xyz/) - one of the largest feminist communities on the internet
|
||||
- [poa.st](https://poa.st/) - the largest Soapbox server on the network
|
||||
|
||||
***For OTP releases,*** *unpack to /var/lib/pleroma instead.*
|
||||
Want to use Soapbox against **any existing Mastodon/Pleroma server?** Try:
|
||||
|
||||
To remove Soapbox and revert to the default pleroma-fe, simply `rm /opt/pleroma/instance/static/index.html` (you can delete other stuff in there too, but be careful not to delete your own HTML files).
|
||||
- [fe.soapbox.pub](https://fe.soapbox.pub) - enter your server's domain name to use Soapbox on any server!
|
||||
|
||||
## :elephant: Deploy on Mastodon
|
||||
# 🚀 Starting Your Own Server
|
||||
|
||||
See [Installing Soapbox over Mastodon](https://docs.soapbox.pub/frontend/administration/mastodon/).
|
||||
Starting your own server is one of the best ways to have freedom online! We recommend installing **Rebased+Soapbox**.
|
||||
|
||||
## How does it work?
|
||||
See here for a detailed setup guide: [Installing Rebased+Soapbox](https://soapbox.pub/install/)
|
||||
|
||||
Soapbox is a [single-page application (SPA)](https://en.wikipedia.org/wiki/Single-page_application) that runs entirely in the browser with JavaScript.
|
||||
# Adding Soapbox to an Existing Server
|
||||
|
||||
It has a single HTML file, `index.html`, responsible only for loading the required JavaScript and CSS.
|
||||
It interacts with the backend through [XMLHttpRequest (XHR)](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest).
|
||||
Already have a server? No problem — it is still possible to use Soapbox.
|
||||
|
||||
Here is a simplified example with Nginx:
|
||||
- [Deploying on Pleroma](https://docs.soapbox.pub/frontend/installing/#install-soapbox)
|
||||
- [Deploying on Mastodon](https://docs.soapbox.pub/frontend/administration/mastodon/)
|
||||
|
||||
```nginx
|
||||
location /api {
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
> 💡 If using Pleroma, it's recommended to [upgrade it to Rebased](https://gitlab.com/-/snippets/2411739). This comes with better support and many new features, helping you get the most out of Soapbox.
|
||||
|
||||
location / {
|
||||
root /opt/soapbox;
|
||||
try_files $uri index.html;
|
||||
}
|
||||
```
|
||||
# Developing Soapbox
|
||||
|
||||
(See [`mastodon.conf`](https://gitlab.com/soapbox-pub/soapbox/-/blob/develop/installation/mastodon.conf) for a full example.)
|
||||
tl;dr — `git clone`, `yarn`, and `yarn dev`.
|
||||
|
||||
Soapbox incorporates much of the [Mastodon API](https://docs.joinmastodon.org/methods/), [Pleroma API](https://api.pleroma.social/), and more.
|
||||
It detects features supported by the backend to provide the right experience for the backend.
|
||||
For detailed guides, see these pages:
|
||||
|
||||
# Running locally
|
||||
1. [Soapbox local development](https://docs.soapbox.pub/frontend/development/running-locally/)
|
||||
2. [yarn commands](https://docs.soapbox.pub/frontend/development/yarn-commands/)
|
||||
3. [How it works](https://docs.soapbox.pub/frontend/development/how-it-works/)
|
||||
4. [Environment variables](https://docs.soapbox.pub/frontend/development/local-config/)
|
||||
5. [Developing a backend](https://docs.soapbox.pub/frontend/development/developing-backend/)
|
||||
|
||||
To get it running, just clone the repo:
|
||||
|
||||
```sh
|
||||
git clone https://gitlab.com/soapbox-pub/soapbox.git
|
||||
cd soapbox
|
||||
```
|
||||
|
||||
Ensure that Node.js and Yarn are installed, then install dependencies:
|
||||
|
||||
```sh
|
||||
yarn
|
||||
```
|
||||
|
||||
Finally, run the dev server:
|
||||
|
||||
```sh
|
||||
yarn dev
|
||||
```
|
||||
|
||||
**That's it!** :tada:
|
||||
|
||||
It will serve at `http://localhost:3036` by default.
|
||||
|
||||
You should see an input box - just enter the domain name of your instance to log in.
|
||||
|
||||
Tip: you can even enter a local instance like `http://localhost:3000`!
|
||||
|
||||
### Troubleshooting: `ERROR: NODE_ENV must be set`
|
||||
|
||||
Create a `.env` file if you haven't already.
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
And ensure that it contains `NODE_ENV=development`.
|
||||
Try again.
|
||||
|
||||
### Troubleshooting: it's not working!
|
||||
|
||||
Run `node -V` and compare your Node.js version with the version in [`.tool-versions`](https://gitlab.com/soapbox-pub/soapbox/-/blob/develop/.tool-versions).
|
||||
If they don't match, try installing [asdf](https://asdf-vm.com/).
|
||||
|
||||
## Local Dev Configuration
|
||||
|
||||
The following configuration variables are supported supported in local development.
|
||||
Edit `.env` to set them.
|
||||
|
||||
All configuration is optional, except `NODE_ENV`.
|
||||
|
||||
#### `NODE_ENV`
|
||||
|
||||
The Node environment.
|
||||
Soapbox checks for the following options:
|
||||
|
||||
- `development` - What you should use while developing Soapbox.
|
||||
- `production` - Use when compiling to deploy to a live server.
|
||||
- `test` - Use when running automated tests.
|
||||
|
||||
#### `BACKEND_URL`
|
||||
|
||||
URL to the backend server.
|
||||
Can be http or https, and can include a port.
|
||||
For https, be sure to also set `PROXY_HTTPS_INSECURE=true`.
|
||||
|
||||
**Default:** `http://localhost:4000`
|
||||
|
||||
#### `PROXY_HTTPS_INSECURE`
|
||||
|
||||
Allows using an HTTPS backend if set to `true`.
|
||||
|
||||
This is needed if `BACKEND_URL` is set to an `https://` value.
|
||||
[More info](https://stackoverflow.com/a/48624590/8811886).
|
||||
|
||||
**Default:** `false`
|
||||
|
||||
# Yarn Commands
|
||||
|
||||
The following commands are supported.
|
||||
You must set `NODE_ENV` to use these commands.
|
||||
To do so, you can add the following line to your `.env` file:
|
||||
|
||||
```sh
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
#### Local dev server
|
||||
- `yarn dev` - Run the local dev server.
|
||||
|
||||
#### Building
|
||||
- `yarn build` - Compile without a dev server, into `/static` directory.
|
||||
|
||||
#### Translations
|
||||
- `yarn manage:translations` - Normalizes translation files. Should always be run after editing i18n strings.
|
||||
|
||||
#### Tests
|
||||
- `yarn test:all` - Runs all tests and linters.
|
||||
|
||||
- `yarn test` - Runs Jest for frontend unit tests.
|
||||
|
||||
- `yarn lint` - Runs all linters.
|
||||
|
||||
- `yarn lint:js` - Runs only JavaScript linter.
|
||||
|
||||
- `yarn lint:sass` - Runs only SASS linter.
|
||||
|
||||
# Contributing
|
||||
## Contributing
|
||||
|
||||
We welcome contributions to this project.
|
||||
To contribute, see [Contributing to Soapbox](docs/contributing.md).
|
||||
|
||||
# Customization
|
||||
Translators can help by providing [translations through Weblate](https://hosted.weblate.org/projects/soapbox-pub/soapbox/).
|
||||
Native speakers from all around the world are welcome!
|
||||
|
||||
Soapbox supports customization of the user interface, to allow per-instance branding and other features.
|
||||
Some examples include:
|
||||
# Project Philosophy
|
||||
|
||||
- Instance name
|
||||
- Site logo
|
||||
- Favicon
|
||||
- About page
|
||||
- Terms of Service page
|
||||
- Privacy Policy page
|
||||
- Copyright Policy (DMCA) page
|
||||
- Promo panel list items, e.g. blog site link
|
||||
- Soapbox extensions, e.g. Patron module
|
||||
- Default settings, e.g. default theme
|
||||
Soapbox was born out of the need to build independent platforms with **a unique identity and brand**.
|
||||
|
||||
More details can be found in [Customizing Soapbox](docs/customization.md).
|
||||
This is in contrast to Mastodon's idea, where all servers are called "Mastodon" and use the Mastodon colors and logo. Users won't see the word "Soapbox" throughout the UI, they'll see the name of **your website** and your logo. To facilitate this, Soapbox has a robust customization UI and integrated moderation tools. Large servers are a priority.
|
||||
|
||||
One disadvantage of this approach is that it does not help the software spread. Some of the biggest servers on the network and running Soapbox and people don't even know it!
|
||||
|
||||
# License & Credits
|
||||
|
||||
Soapbox is based on [Gab Social](https://code.gab.com/gab/social/gab-social)'s frontend which is in turn based on [Mastodon](https://github.com/tootsuite/mastodon/)'s frontend.
|
||||
|
||||
- `static/sounds/chat.mp3` and `static/sounds/chat.oga` are from [notificationsounds.com](https://notificationsounds.com/notification-sounds/intuition-561) licensed under CC BY 4.0.
|
||||
© Alex Gleason & other Soapbox contributors
|
||||
© Eugen Rochko & other Mastodon contributors
|
||||
© Gab AI, Inc.
|
||||
|
||||
Soapbox is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -205,8 +83,8 @@ the Free Software Foundation, either version 3 of the License, or
|
|||
|
||||
Soapbox is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with Soapbox. If not, see <https://www.gnu.org/licenses/>.
|
||||
along with Soapbox. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
# Sound licenses
|
||||
|
||||
- `chat.mp3`
|
||||
- `chat.oga`
|
||||
|
||||
© [notificationsounds.com](https://notificationsounds.com/notification-sounds/intuition-561), licensed under [CC BY 4.0](https://creativecommons.org/licenses/by-sa/4.0/).
|
|
@ -103,6 +103,19 @@ const updateConfig = (configs: Record<string, any>[]) =>
|
|||
});
|
||||
};
|
||||
|
||||
const updateSoapboxConfig = (data: Record<string, any>) =>
|
||||
(dispatch: AppDispatch, _getState: () => RootState) => {
|
||||
const params = [{
|
||||
group: ':pleroma',
|
||||
key: ':frontend_configurations',
|
||||
value: [{
|
||||
tuple: [':soapbox_fe', data],
|
||||
}],
|
||||
}];
|
||||
|
||||
return dispatch(updateConfig(params));
|
||||
};
|
||||
|
||||
const fetchMastodonReports = (params: Record<string, any>) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) =>
|
||||
api(getState)
|
||||
|
@ -585,6 +598,7 @@ export {
|
|||
ADMIN_USERS_UNSUGGEST_FAIL,
|
||||
fetchConfig,
|
||||
updateConfig,
|
||||
updateSoapboxConfig,
|
||||
fetchReports,
|
||||
closeReports,
|
||||
fetchUsers,
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
|
||||
import List, { ListItem } from './list';
|
||||
|
||||
interface IRadioGroup {
|
||||
onChange: React.ChangeEventHandler
|
||||
children: React.ReactElement<{ onChange: React.ChangeEventHandler }>[]
|
||||
}
|
||||
|
||||
const RadioGroup = ({ onChange, children }: IRadioGroup) => {
|
||||
const childrenWithProps = React.Children.map(children, child =>
|
||||
React.cloneElement(child, { onChange }),
|
||||
);
|
||||
|
||||
return <List>{childrenWithProps}</List>;
|
||||
};
|
||||
|
||||
interface IRadioItem {
|
||||
label: React.ReactNode,
|
||||
hint?: React.ReactNode,
|
||||
value: string,
|
||||
checked: boolean,
|
||||
onChange?: React.ChangeEventHandler,
|
||||
}
|
||||
|
||||
const RadioItem: React.FC<IRadioItem> = ({ label, hint, checked = false, onChange, value }) => {
|
||||
return (
|
||||
<ListItem label={label} hint={hint}>
|
||||
<input
|
||||
type='radio'
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
className='h-4 w-4 border-gray-300 text-primary-600 focus:ring-primary-500'
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
RadioGroup,
|
||||
RadioItem,
|
||||
};
|
|
@ -41,6 +41,7 @@ export { default as PhoneInput } from './phone-input/phone-input';
|
|||
export { default as ProgressBar } from './progress-bar/progress-bar';
|
||||
export { default as RadioButton } from './radio-button/radio-button';
|
||||
export { default as Select } from './select/select';
|
||||
export { default as Slider } from './slider/slider';
|
||||
export { default as Spinner } from './spinner/spinner';
|
||||
export { default as Stack } from './stack/stack';
|
||||
export { default as Streamfield } from './streamfield/streamfield';
|
||||
|
|
|
@ -0,0 +1,124 @@
|
|||
import throttle from 'lodash/throttle';
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
type Point = { x: number, y: number };
|
||||
|
||||
interface ISlider {
|
||||
/** Value between 0 and 1. */
|
||||
value: number
|
||||
/** Callback when the value changes. */
|
||||
onChange(value: number): void
|
||||
}
|
||||
|
||||
/** Draggable slider component. */
|
||||
const Slider: React.FC<ISlider> = ({ value, onChange }) => {
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseDown: React.MouseEventHandler = e => {
|
||||
document.addEventListener('mousemove', handleMouseSlide, true);
|
||||
document.addEventListener('mouseup', handleMouseUp, true);
|
||||
document.addEventListener('touchmove', handleMouseSlide, true);
|
||||
document.addEventListener('touchend', handleMouseUp, true);
|
||||
|
||||
handleMouseSlide(e);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseSlide, true);
|
||||
document.removeEventListener('mouseup', handleMouseUp, true);
|
||||
document.removeEventListener('touchmove', handleMouseSlide, true);
|
||||
document.removeEventListener('touchend', handleMouseUp, true);
|
||||
};
|
||||
|
||||
const handleMouseSlide = throttle(e => {
|
||||
if (node.current) {
|
||||
const { x } = getPointerPosition(node.current, e);
|
||||
|
||||
if (!isNaN(x)) {
|
||||
let slideamt = x;
|
||||
|
||||
if (x > 1) {
|
||||
slideamt = 1;
|
||||
} else if (x < 0) {
|
||||
slideamt = 0;
|
||||
}
|
||||
|
||||
onChange(slideamt);
|
||||
}
|
||||
}
|
||||
}, 60);
|
||||
|
||||
return (
|
||||
<div
|
||||
className='inline-flex cursor-pointer h-6 relative transition'
|
||||
onMouseDown={handleMouseDown}
|
||||
ref={node}
|
||||
>
|
||||
<div className='w-full h-1 bg-primary-200 dark:bg-primary-700 absolute top-1/2 -translate-y-1/2 rounded-full' />
|
||||
<div className='h-1 bg-accent-500 absolute top-1/2 -translate-y-1/2 rounded-full' style={{ width: `${value * 100}%` }} />
|
||||
<span
|
||||
className='bg-accent-500 absolute rounded-full w-3 h-3 -ml-1.5 top-1/2 -translate-y-1/2 z-10 shadow'
|
||||
tabIndex={0}
|
||||
style={{ left: `${value * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const findElementPosition = (el: HTMLElement) => {
|
||||
let box;
|
||||
|
||||
if (el.getBoundingClientRect && el.parentNode) {
|
||||
box = el.getBoundingClientRect();
|
||||
}
|
||||
|
||||
if (!box) {
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const docEl = document.documentElement;
|
||||
const body = document.body;
|
||||
|
||||
const clientLeft = docEl.clientLeft || body.clientLeft || 0;
|
||||
const scrollLeft = window.pageXOffset || body.scrollLeft;
|
||||
const left = (box.left + scrollLeft) - clientLeft;
|
||||
|
||||
const clientTop = docEl.clientTop || body.clientTop || 0;
|
||||
const scrollTop = window.pageYOffset || body.scrollTop;
|
||||
const top = (box.top + scrollTop) - clientTop;
|
||||
|
||||
return {
|
||||
left: Math.round(left),
|
||||
top: Math.round(top),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const getPointerPosition = (el: HTMLElement, event: MouseEvent & TouchEvent): Point => {
|
||||
const box = findElementPosition(el);
|
||||
const boxW = el.offsetWidth;
|
||||
const boxH = el.offsetHeight;
|
||||
const boxY = box.top;
|
||||
const boxX = box.left;
|
||||
|
||||
let pageY = event.pageY;
|
||||
let pageX = event.pageX;
|
||||
|
||||
if (event.changedTouches) {
|
||||
pageX = event.changedTouches[0].pageX;
|
||||
pageY = event.changedTouches[0].pageY;
|
||||
}
|
||||
|
||||
return {
|
||||
y: Math.max(0, Math.min(1, (pageY - boxY) / boxH)),
|
||||
x: Math.max(0, Math.min(1, (pageX - boxX) / boxW)),
|
||||
};
|
||||
};
|
||||
|
||||
export default Slider;
|
|
@ -41,6 +41,7 @@ import {
|
|||
useInstance,
|
||||
} from 'soapbox/hooks';
|
||||
import MESSAGES from 'soapbox/locales/messages';
|
||||
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
import { useCachedLocationHandler } from 'soapbox/utils/redirect';
|
||||
import { generateThemeCss } from 'soapbox/utils/theme';
|
||||
|
@ -267,8 +268,9 @@ const SoapboxHead: React.FC<ISoapboxHead> = ({ children }) => {
|
|||
const settings = useSettings();
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
||||
const demo = !!settings.get('demo');
|
||||
const darkMode = useTheme() === 'dark';
|
||||
const themeCss = generateThemeCss(soapboxConfig);
|
||||
const themeCss = generateThemeCss(demo ? normalizeSoapboxConfig({ brandColor: '#0482d8' }) : soapboxConfig);
|
||||
|
||||
const bodyClass = classNames('bg-white dark:bg-gray-800 text-base h-full', {
|
||||
'no-reduce-motion': !settings.get('reduceMotion'),
|
||||
|
|
|
@ -526,11 +526,11 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
};
|
||||
|
||||
const renderMessageButton = () => {
|
||||
if (features.chatsWithFollowers) { // Truth Social
|
||||
if (!ownAccount || !account || account.id === ownAccount?.id) {
|
||||
return null;
|
||||
}
|
||||
if (!ownAccount || !account || account.id === ownAccount?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (features.chatsWithFollowers) { // Truth Social
|
||||
const canChat = account.relationship?.followed_by;
|
||||
if (!canChat) {
|
||||
return null;
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
import React from 'react';
|
||||
import { FormattedNumber } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import { isNumber } from 'soapbox/utils/numbers';
|
||||
|
||||
interface IDashCounter {
|
||||
count: number | undefined
|
||||
label: React.ReactNode
|
||||
to?: string
|
||||
percent?: boolean
|
||||
}
|
||||
|
||||
/** Displays a (potentially clickable) dashboard statistic. */
|
||||
const DashCounter: React.FC<IDashCounter> = ({ count, label, to = '#', percent = false }) => {
|
||||
|
||||
if (!isNumber(count)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
className='bg-gray-200 dark:bg-gray-800 p-4 rounded flex flex-col items-center space-y-2 hover:-translate-y-1 transition-transform cursor-pointer'
|
||||
to={to}
|
||||
>
|
||||
<Text align='center' size='2xl' weight='medium'>
|
||||
<FormattedNumber
|
||||
value={count}
|
||||
style={percent ? 'unit' : undefined}
|
||||
unit={percent ? 'percent' : undefined}
|
||||
/>
|
||||
</Text>
|
||||
<Text align='center'>
|
||||
{label}
|
||||
</Text>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
interface IDashCounters {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** Wrapper container for dash counters. */
|
||||
const DashCounters: React.FC<IDashCounters> = ({ children }) => {
|
||||
return (
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2'>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
DashCounter,
|
||||
DashCounters,
|
||||
};
|
|
@ -3,12 +3,7 @@ import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
|||
|
||||
import { updateConfig } from 'soapbox/actions/admin';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import {
|
||||
SimpleForm,
|
||||
FieldsGroup,
|
||||
RadioGroup,
|
||||
RadioItem,
|
||||
} from 'soapbox/features/forms';
|
||||
import { RadioGroup, RadioItem } from 'soapbox/components/radio';
|
||||
import { useAppDispatch, useInstance } from 'soapbox/hooks';
|
||||
|
||||
import type { Instance } from 'soapbox/types/entities';
|
||||
|
@ -54,33 +49,26 @@ const RegistrationModePicker: React.FC = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<SimpleForm>
|
||||
<FieldsGroup>
|
||||
<RadioGroup
|
||||
label={<FormattedMessage id='admin.dashboard.registration_mode_label' defaultMessage='Registrations' />}
|
||||
onChange={onChange}
|
||||
>
|
||||
<RadioItem
|
||||
label={<FormattedMessage id='admin.dashboard.registration_mode.open_label' defaultMessage='Open' />}
|
||||
hint={<FormattedMessage id='admin.dashboard.registration_mode.open_hint' defaultMessage='Anyone can join.' />}
|
||||
checked={mode === 'open'}
|
||||
value='open'
|
||||
/>
|
||||
<RadioItem
|
||||
label={<FormattedMessage id='admin.dashboard.registration_mode.approval_label' defaultMessage='Approval Required' />}
|
||||
hint={<FormattedMessage id='admin.dashboard.registration_mode.approval_hint' defaultMessage='Users can sign up, but their account only gets activated when an admin approves it.' />}
|
||||
checked={mode === 'approval'}
|
||||
value='approval'
|
||||
/>
|
||||
<RadioItem
|
||||
label={<FormattedMessage id='admin.dashboard.registration_mode.closed_label' defaultMessage='Closed' />}
|
||||
hint={<FormattedMessage id='admin.dashboard.registration_mode.closed_hint' defaultMessage='Nobody can sign up. You can still invite people.' />}
|
||||
checked={mode === 'closed'}
|
||||
value='closed'
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FieldsGroup>
|
||||
</SimpleForm>
|
||||
<RadioGroup onChange={onChange}>
|
||||
<RadioItem
|
||||
label={<FormattedMessage id='admin.dashboard.registration_mode.open_label' defaultMessage='Open' />}
|
||||
hint={<FormattedMessage id='admin.dashboard.registration_mode.open_hint' defaultMessage='Anyone can join.' />}
|
||||
checked={mode === 'open'}
|
||||
value='open'
|
||||
/>
|
||||
<RadioItem
|
||||
label={<FormattedMessage id='admin.dashboard.registration_mode.approval_label' defaultMessage='Approval Required' />}
|
||||
hint={<FormattedMessage id='admin.dashboard.registration_mode.approval_hint' defaultMessage='Users can sign up, but their account only gets activated when an admin approves it.' />}
|
||||
checked={mode === 'approval'}
|
||||
value='approval'
|
||||
/>
|
||||
<RadioItem
|
||||
label={<FormattedMessage id='admin.dashboard.registration_mode.closed_label' defaultMessage='Closed' />}
|
||||
hint={<FormattedMessage id='admin.dashboard.registration_mode.closed_hint' defaultMessage='Nobody can sign up. You can still invite people.' />}
|
||||
checked={mode === 'closed'}
|
||||
value='closed'
|
||||
/>
|
||||
</RadioGroup>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
|||
import { approveUsers } from 'soapbox/actions/admin';
|
||||
import { rejectUserModal } from 'soapbox/actions/moderation';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import IconButton from 'soapbox/components/icon-button';
|
||||
import { Stack, HStack, Text, IconButton } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
|
||||
|
@ -45,16 +45,31 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className='unapproved-account'>
|
||||
<div className='unapproved-account__bio'>
|
||||
<div className='unapproved-account__nickname'>@{account.get('acct')}</div>
|
||||
<blockquote className='md'>{adminAccount?.invite_request || ''}</blockquote>
|
||||
</div>
|
||||
<div className='unapproved-account__actions'>
|
||||
<IconButton src={require('@tabler/icons/check.svg')} onClick={handleApprove} />
|
||||
<IconButton src={require('@tabler/icons/x.svg')} onClick={handleReject} />
|
||||
</div>
|
||||
</div>
|
||||
<HStack space={4} justifyContent='between'>
|
||||
<Stack space={1}>
|
||||
<Text weight='semibold'>
|
||||
@{account.get('acct')}
|
||||
</Text>
|
||||
<Text tag='blockquote' size='sm'>
|
||||
{adminAccount?.invite_request || ''}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<HStack space={2} alignItems='center'>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/check.svg')}
|
||||
onClick={handleApprove}
|
||||
theme='outlined'
|
||||
iconClassName='p-1 text-gray-600 dark:text-gray-400'
|
||||
/>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/x.svg')}
|
||||
onClick={handleReject}
|
||||
theme='outlined'
|
||||
iconClassName='p-1 text-gray-600 dark:text-gray-400'
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -3,8 +3,9 @@ import { defineMessages, FormattedDate, useIntl } from 'react-intl';
|
|||
|
||||
import { fetchModerationLog } from 'soapbox/actions/admin';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import { Column, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { AdminLog } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.admin.moderation_log', defaultMessage: 'Moderation Log' },
|
||||
|
@ -18,6 +19,7 @@ const ModerationLog = () => {
|
|||
const items = useAppSelector((state) => {
|
||||
return state.admin_log.index.map((i) => state.admin_log.items.get(String(i)));
|
||||
});
|
||||
|
||||
const hasMore = useAppSelector((state) => state.admin_log.total - state.admin_log.index.count() > 0);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
@ -54,26 +56,38 @@ const ModerationLog = () => {
|
|||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
className='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
|
||||
>
|
||||
{items.map((item) => item && (
|
||||
<div className='logentry' key={item.id}>
|
||||
<div className='logentry__message'>{item.message}</div>
|
||||
<div className='logentry__timestamp'>
|
||||
<FormattedDate
|
||||
value={new Date(item.time * 1000)}
|
||||
hour12
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
hour='numeric'
|
||||
minute='2-digit'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{items.map(item => item && (
|
||||
<LogItem key={item.id} log={item} />
|
||||
))}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
interface ILogItem {
|
||||
log: AdminLog
|
||||
}
|
||||
|
||||
const LogItem: React.FC<ILogItem> = ({ log }) => {
|
||||
return (
|
||||
<Stack space={2} className='p-4'>
|
||||
<Text>{log.message}</Text>
|
||||
|
||||
<Text theme='muted' size='xs'>
|
||||
<FormattedDate
|
||||
value={new Date(log.time * 1000)}
|
||||
hour12
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
hour='numeric'
|
||||
minute='2-digit'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModerationLog;
|
||||
|
|
|
@ -33,9 +33,12 @@ const AwaitingApproval: React.FC = () => {
|
|||
showLoading={showLoading}
|
||||
scrollKey='awaiting-approval'
|
||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||
className='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
|
||||
>
|
||||
{accountIds.map(id => (
|
||||
<UnapprovedAccount accountId={id} key={id} />
|
||||
<div key={id} className='py-4 px-5'>
|
||||
<UnapprovedAccount accountId={id} />
|
||||
</div>
|
||||
))}
|
||||
</ScrollableList>
|
||||
);
|
||||
|
|
|
@ -1,44 +1,49 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage, FormattedNumber } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/actions/email-list';
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { CardTitle, Icon, IconButton, Stack } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useOwnAccount, useFeatures, useInstance } from 'soapbox/hooks';
|
||||
import sourceCode from 'soapbox/utils/code';
|
||||
import { download } from 'soapbox/utils/download';
|
||||
import { parseVersion } from 'soapbox/utils/features';
|
||||
import { isNumber } from 'soapbox/utils/numbers';
|
||||
|
||||
import { DashCounter, DashCounters } from '../components/dashcounter';
|
||||
import RegistrationModePicker from '../components/registration-mode-picker';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
const instance = useInstance();
|
||||
const features = useFeatures();
|
||||
const account = useOwnAccount();
|
||||
|
||||
const handleSubscribersClick: React.MouseEventHandler = e => {
|
||||
dispatch(getSubscribersCsv()).then((response) => {
|
||||
download(response, 'subscribers.csv');
|
||||
dispatch(getSubscribersCsv()).then(({ data }) => {
|
||||
download(data, 'subscribers.csv');
|
||||
}).catch(() => {});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleUnsubscribersClick: React.MouseEventHandler = e => {
|
||||
dispatch(getUnsubscribersCsv()).then((response) => {
|
||||
download(response, 'unsubscribers.csv');
|
||||
dispatch(getUnsubscribersCsv()).then(({ data }) => {
|
||||
download(data, 'unsubscribers.csv');
|
||||
}).catch(() => {});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleCombinedClick: React.MouseEventHandler = e => {
|
||||
dispatch(getCombinedCsv()).then((response) => {
|
||||
download(response, 'combined.csv');
|
||||
dispatch(getCombinedCsv()).then(({ data }) => {
|
||||
download(data, 'combined.csv');
|
||||
}).catch(() => {});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const navigateToSoapboxConfig = () => history.push('/soapbox/config');
|
||||
const navigateToModerationLog = () => history.push('/soapbox/admin/log');
|
||||
|
||||
const v = parseVersion(instance.version);
|
||||
|
||||
const userCount = instance.stats.get('user_count');
|
||||
|
@ -46,87 +51,121 @@ const Dashboard: React.FC = () => {
|
|||
const domainCount = instance.stats.get('domain_count');
|
||||
|
||||
const mau = instance.pleroma.getIn(['stats', 'mau']) as number | undefined;
|
||||
const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : null;
|
||||
const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : undefined;
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='dashcounters mt-8'>
|
||||
{isNumber(mau) && (
|
||||
<div className='dashcounter'>
|
||||
<Text align='center' size='2xl' weight='medium'>
|
||||
<FormattedNumber value={mau} />
|
||||
</Text>
|
||||
<Text align='center'>
|
||||
<FormattedMessage id='admin.dashcounters.mau_label' defaultMessage='monthly active users' />
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{isNumber(userCount) && (
|
||||
<Link className='dashcounter' to='/soapbox/admin/users'>
|
||||
<Text align='center' size='2xl' weight='medium'>
|
||||
<FormattedNumber value={userCount} />
|
||||
</Text>
|
||||
<Text align='center'>
|
||||
<FormattedMessage id='admin.dashcounters.user_count_label' defaultMessage='total users' />
|
||||
</Text>
|
||||
</Link>
|
||||
)}
|
||||
{isNumber(retention) && (
|
||||
<div className='dashcounter'>
|
||||
<Text align='center' size='2xl' weight='medium'>
|
||||
{retention}%
|
||||
</Text>
|
||||
<Text align='center'>
|
||||
<FormattedMessage id='admin.dashcounters.retention_label' defaultMessage='user retention' />
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{isNumber(statusCount) && (
|
||||
<Link className='dashcounter' to='/timeline/local'>
|
||||
<Text align='center' size='2xl' weight='medium'>
|
||||
<FormattedNumber value={statusCount} />
|
||||
</Text>
|
||||
<Text align='center'>
|
||||
<FormattedMessage id='admin.dashcounters.status_count_label' defaultMessage='posts' />
|
||||
</Text>
|
||||
</Link>
|
||||
)}
|
||||
{isNumber(domainCount) && (
|
||||
<div className='dashcounter'>
|
||||
<Text align='center' size='2xl' weight='medium'>
|
||||
<FormattedNumber value={domainCount} />
|
||||
</Text>
|
||||
<Text align='center'>
|
||||
<FormattedMessage id='admin.dashcounters.domain_count_label' defaultMessage='peers' />
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Stack space={6} className='mt-4'>
|
||||
<DashCounters>
|
||||
<DashCounter
|
||||
count={mau}
|
||||
label={<FormattedMessage id='admin.dashcounters.mau_label' defaultMessage='monthly active users' />}
|
||||
/>
|
||||
<DashCounter
|
||||
to='/soapbox/admin/users'
|
||||
count={userCount}
|
||||
label={<FormattedMessage id='admin.dashcounters.user_count_label' defaultMessage='total users' />}
|
||||
/>
|
||||
<DashCounter
|
||||
count={retention}
|
||||
label={<FormattedMessage id='admin.dashcounters.retention_label' defaultMessage='user retention' />}
|
||||
percent
|
||||
/>
|
||||
<DashCounter
|
||||
to='/timeline/local'
|
||||
count={statusCount}
|
||||
label={<FormattedMessage id='admin.dashcounters.status_count_label' defaultMessage='posts' />}
|
||||
/>
|
||||
<DashCounter
|
||||
count={domainCount}
|
||||
label={<FormattedMessage id='admin.dashcounters.domain_count_label' defaultMessage='peers' />}
|
||||
/>
|
||||
</DashCounters>
|
||||
|
||||
{account.admin && <RegistrationModePicker />}
|
||||
|
||||
<div className='dashwidgets'>
|
||||
<div className='dashwidget'>
|
||||
<h4><FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' /></h4>
|
||||
<ul>
|
||||
<li>{sourceCode.displayName} <span className='pull-right'>{sourceCode.version}</span></li>
|
||||
<li>{v.software + (v.build ? `+${v.build}` : '')} <span className='pull-right'>{v.version}</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
{features.emailList && account.admin && (
|
||||
<div className='dashwidget'>
|
||||
<h4><FormattedMessage id='admin.dashwidgets.email_list_header' defaultMessage='Email list' /></h4>
|
||||
<ul>
|
||||
<li><a href='#' onClick={handleSubscribersClick} target='_blank'>subscribers.csv</a></li>
|
||||
<li><a href='#' onClick={handleUnsubscribersClick} target='_blank'>unsubscribers.csv</a></li>
|
||||
<li><a href='#' onClick={handleCombinedClick} target='_blank'>combined.csv</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<List>
|
||||
{account.admin && (
|
||||
<ListItem
|
||||
onClick={navigateToSoapboxConfig}
|
||||
label={<FormattedMessage id='navigation_bar.soapbox_config' defaultMessage='Soapbox config' />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
<ListItem
|
||||
onClick={navigateToModerationLog}
|
||||
label={<FormattedMessage id='column.admin.moderation_log' defaultMessage='Moderation Log' />}
|
||||
/>
|
||||
</List>
|
||||
|
||||
{account.admin && (
|
||||
<>
|
||||
<CardTitle
|
||||
title={<FormattedMessage id='admin.dashboard.registration_mode_label' defaultMessage='Registrations' />}
|
||||
/>
|
||||
|
||||
<RegistrationModePicker />
|
||||
</>
|
||||
)}
|
||||
|
||||
<CardTitle
|
||||
title={<FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' />}
|
||||
/>
|
||||
|
||||
<List>
|
||||
<ListItem label={<FormattedMessage id='admin.software.frontend' defaultMessage='Frontend' />}>
|
||||
<a
|
||||
href={sourceCode.ref ? `${sourceCode.url}/tree/${sourceCode.ref}` : sourceCode.url}
|
||||
className='flex space-x-1 items-center truncate'
|
||||
target='_blank'
|
||||
>
|
||||
<span>{sourceCode.displayName} {sourceCode.version}</span>
|
||||
|
||||
<Icon
|
||||
className='w-4 h-4'
|
||||
src={require('@tabler/icons/external-link.svg')}
|
||||
/>
|
||||
</a>
|
||||
</ListItem>
|
||||
|
||||
<ListItem label={<FormattedMessage id='admin.software.backend' defaultMessage='Backend' />}>
|
||||
<span>{v.software + (v.build ? `+${v.build}` : '')} {v.version}</span>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
{(features.emailList && account.admin) && (
|
||||
<>
|
||||
<CardTitle
|
||||
title={<FormattedMessage id='admin.dashwidgets.email_list_header' defaultMessage='Email list' />}
|
||||
/>
|
||||
|
||||
<List>
|
||||
<ListItem label='subscribers.csv'>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/download.svg')}
|
||||
onClick={handleSubscribersClick}
|
||||
iconClassName='w-5 h-5'
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem label='unsubscribers.csv'>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/download.svg')}
|
||||
onClick={handleUnsubscribersClick}
|
||||
iconClassName='w-5 h-5'
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem label='combined.csv'>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/download.svg')}
|
||||
onClick={handleCombinedClick}
|
||||
iconClassName='w-5 h-5'
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -36,7 +36,7 @@ const Welcome = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Stack className='py-20 px-4 sm:px-0' data-testid='chats-welcome'>
|
||||
<Stack className='py-20 px-4 sm:px-0 h-full overflow-y-auto' data-testid='chats-welcome'>
|
||||
<div className='w-full sm:w-3/5 xl:w-2/5 mx-auto mb-2.5'>
|
||||
<Text align='center' weight='bold' className='mb-6 text-2xl md:text-3xl leading-8'>
|
||||
{intl.formatMessage(messages.title, { br: <br /> })}
|
||||
|
|
|
@ -103,6 +103,13 @@ const SettingsStore: React.FC = () => {
|
|||
</CardHeader>
|
||||
|
||||
<List>
|
||||
<ListItem
|
||||
label={<FormattedMessage id='preferences.fields.demo_label' defaultMessage='Demo mode' />}
|
||||
hint={<FormattedMessage id='preferences.fields.demo_hint' defaultMessage='Use the default Soapbox logo and color scheme. Useful for taking screenshots.' />}
|
||||
>
|
||||
<SettingToggle settings={settings} settingPath={['demo']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
|
||||
<ListItem label={<FormattedMessage id='preferences.notifications.advanced' defaultMessage='Show all notification categories' />}>
|
||||
<SettingToggle settings={settings} settingPath={['notifications', 'quickFilter', 'advanced']} onChange={onToggleChange} />
|
||||
</ListItem>
|
||||
|
|
|
@ -102,8 +102,8 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
|
|||
};
|
||||
|
||||
const handleExportClick = () => {
|
||||
dispatch(fetchEventIcs(status.id)).then((response) => {
|
||||
download(response, 'calendar.ics');
|
||||
dispatch(fetchEventIcs(status.id)).then(({ data }) => {
|
||||
download(data, 'calendar.ics');
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import classNames from 'clsx';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { Text, Select } from '../../components/ui';
|
||||
import { Select } from '../../components/ui';
|
||||
|
||||
interface IInputContainer {
|
||||
label?: React.ReactNode,
|
||||
|
@ -175,52 +175,6 @@ export const Checkbox: React.FC<ICheckbox> = (props) => (
|
|||
<SimpleInput type='checkbox' {...props} />
|
||||
);
|
||||
|
||||
interface IRadioGroup {
|
||||
label?: React.ReactNode,
|
||||
onChange?: React.ChangeEventHandler,
|
||||
}
|
||||
|
||||
export const RadioGroup: React.FC<IRadioGroup> = (props) => {
|
||||
const { label, children, onChange } = props;
|
||||
|
||||
const childrenWithProps = React.Children.map(children, child =>
|
||||
// @ts-ignore
|
||||
React.cloneElement(child, { onChange }),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='input with_floating_label radio_buttons'>
|
||||
<div className='label_input'>
|
||||
<label>{label}</label>
|
||||
<ul>{childrenWithProps}</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IRadioItem {
|
||||
label?: React.ReactNode,
|
||||
hint?: React.ReactNode,
|
||||
value: string,
|
||||
checked: boolean,
|
||||
onChange?: React.ChangeEventHandler,
|
||||
}
|
||||
|
||||
export const RadioItem: React.FC<IRadioItem> = (props) => {
|
||||
const { current: id } = useRef<string>(uuidv4());
|
||||
const { label, hint, checked = false, ...rest } = props;
|
||||
|
||||
return (
|
||||
<li className='radio'>
|
||||
<label htmlFor={id}>
|
||||
<input id={id} type='radio' checked={checked} {...rest} />
|
||||
<Text>{label}</Text>
|
||||
{hint && <span className='hint'>{hint}</span>}
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISelectDropdown {
|
||||
label?: React.ReactNode,
|
||||
hint?: React.ReactNode,
|
||||
|
|
|
@ -7,7 +7,7 @@ import { useOwnAccount } from 'soapbox/hooks';
|
|||
import { useUpdateCredentials } from 'soapbox/queries/accounts';
|
||||
|
||||
const messages = defineMessages({
|
||||
label: { id: 'settings.messages.label', defaultMessage: 'Allow users you follow to start a new chat with you' },
|
||||
label: { id: 'settings.messages.label', defaultMessage: 'Allow users to start a new chat with you' },
|
||||
});
|
||||
|
||||
const MessagesSettings = () => {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
|
@ -106,7 +106,7 @@ const Settings = () => {
|
|||
{features.chats ? (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle title='Direct Messages' />
|
||||
<CardTitle title={<FormattedMessage id='column.chats' defaultMessage='Chats' />} />
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
|
|
|
@ -9,12 +9,12 @@ import ColorPicker from './color-picker';
|
|||
import type { ColorChangeHandler } from 'react-color';
|
||||
|
||||
interface IColorWithPicker {
|
||||
buttonId: string,
|
||||
value: string,
|
||||
onChange: ColorChangeHandler,
|
||||
className?: string,
|
||||
}
|
||||
|
||||
const ColorWithPicker: React.FC<IColorWithPicker> = ({ buttonId, value, onChange }) => {
|
||||
const ColorWithPicker: React.FC<IColorWithPicker> = ({ value, onChange, className }) => {
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
const [placement, setPlacement] = useState<string | null>(null);
|
||||
|
@ -39,11 +39,10 @@ const ColorWithPicker: React.FC<IColorWithPicker> = ({ buttonId, value, onChange
|
|||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={className}>
|
||||
<div
|
||||
ref={node}
|
||||
id={buttonId}
|
||||
className='w-8 h-8 rounded-md'
|
||||
className='w-full h-full'
|
||||
role='presentation'
|
||||
style={{ background: value }}
|
||||
title={value}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { updateConfig } from 'soapbox/actions/admin';
|
||||
import { updateSoapboxConfig } from 'soapbox/actions/admin';
|
||||
import { uploadMedia } from 'soapbox/actions/media';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
|
@ -25,14 +26,11 @@ import ThemeSelector from 'soapbox/features/ui/components/theme-selector';
|
|||
import { useAppSelector, useAppDispatch, useFeatures } from 'soapbox/hooks';
|
||||
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
|
||||
|
||||
import ColorWithPicker from './components/color-with-picker';
|
||||
import CryptoAddressInput from './components/crypto-address-input';
|
||||
import FooterLinkInput from './components/footer-link-input';
|
||||
import PromoPanelInput from './components/promo-panel-input';
|
||||
import SitePreview from './components/site-preview';
|
||||
|
||||
import type { ColorChangeHandler, ColorResult } from 'react-color';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.soapbox_config', defaultMessage: 'Soapbox config' },
|
||||
saved: { id: 'soapbox_config.saved', defaultMessage: 'Soapbox config saved!' },
|
||||
|
@ -59,7 +57,6 @@ const messages = defineMessages({
|
|||
});
|
||||
|
||||
type ValueGetter<T = Element> = (e: React.ChangeEvent<T>) => any;
|
||||
type ColorValueGetter = (color: ColorResult, event: React.ChangeEvent<HTMLInputElement>) => any;
|
||||
type Template = ImmutableMap<string, any>;
|
||||
type ConfigPath = Array<string | number>;
|
||||
type ThemeChangeHandler = (theme: string) => void;
|
||||
|
@ -72,6 +69,7 @@ const templates: Record<string, Template> = {
|
|||
|
||||
const SoapboxConfig: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const features = useFeatures();
|
||||
|
@ -84,6 +82,8 @@ const SoapboxConfig: React.FC = () => {
|
|||
const [rawJSON, setRawJSON] = useState<string>(JSON.stringify(initialData, null, 2));
|
||||
const [jsonValid, setJsonValid] = useState(true);
|
||||
|
||||
const navigateToThemeEditor = () => history.push('/soapbox/admin/theme');
|
||||
|
||||
const soapbox = useMemo(() => {
|
||||
return normalizeSoapboxConfig(data);
|
||||
}, [data]);
|
||||
|
@ -99,18 +99,8 @@ const SoapboxConfig: React.FC = () => {
|
|||
setJsonValid(true);
|
||||
};
|
||||
|
||||
const getParams = () => {
|
||||
return [{
|
||||
group: ':pleroma',
|
||||
key: ':frontend_configurations',
|
||||
value: [{
|
||||
tuple: [':soapbox_fe', data.toJS()],
|
||||
}],
|
||||
}];
|
||||
};
|
||||
|
||||
const handleSubmit: React.FormEventHandler = (e) => {
|
||||
dispatch(updateConfig(getParams())).then(() => {
|
||||
dispatch(updateSoapboxConfig(data.toJS())).then(() => {
|
||||
setLoading(false);
|
||||
dispatch(snackbar.success(intl.formatMessage(messages.saved)));
|
||||
}).catch(() => {
|
||||
|
@ -132,12 +122,6 @@ const SoapboxConfig: React.FC = () => {
|
|||
};
|
||||
};
|
||||
|
||||
const handleColorChange = (path: ConfigPath, getValue: ColorValueGetter): ColorChangeHandler => {
|
||||
return (color, event) => {
|
||||
setConfig(path, getValue(color, event));
|
||||
};
|
||||
};
|
||||
|
||||
const handleFileChange = (path: ConfigPath): React.ChangeEventHandler<HTMLInputElement> => {
|
||||
return e => {
|
||||
const data = new FormData();
|
||||
|
@ -224,21 +208,10 @@ const SoapboxConfig: React.FC = () => {
|
|||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem label={<FormattedMessage id='soapbox_config.fields.brand_color_label' defaultMessage='Brand color' />}>
|
||||
<ColorWithPicker
|
||||
buttonId='brandColor'
|
||||
value={soapbox.brandColor}
|
||||
onChange={handleColorChange(['brandColor'], (color) => color.hex)}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem label={<FormattedMessage id='soapbox_config.fields.accent_color_label' defaultMessage='Accent color' />}>
|
||||
<ColorWithPicker
|
||||
buttonId='accentColor'
|
||||
value={soapbox.accentColor}
|
||||
onChange={handleColorChange(['accentColor'], (color) => color.hex)}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
label={<FormattedMessage id='soapbox_config.fields.edit_theme_label' defaultMessage='Edit theme' />}
|
||||
onClick={navigateToThemeEditor}
|
||||
/>
|
||||
</List>
|
||||
|
||||
<CardHeader>
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
|
||||
import ColorWithPicker from 'soapbox/features/soapbox-config/components/color-with-picker';
|
||||
|
||||
import type { ColorChangeHandler } from 'react-color';
|
||||
|
||||
interface IColor {
|
||||
color: string,
|
||||
onChange: (color: string) => void,
|
||||
}
|
||||
|
||||
/** Color input. */
|
||||
const Color: React.FC<IColor> = ({ color, onChange }) => {
|
||||
|
||||
const handleChange: ColorChangeHandler = (result) => {
|
||||
onChange(result.hex);
|
||||
};
|
||||
|
||||
return (
|
||||
<ColorWithPicker
|
||||
className='w-full h-full'
|
||||
value={color}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Color;
|
|
@ -0,0 +1,67 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { HStack, Stack, Slider } from 'soapbox/components/ui';
|
||||
import { usePrevious } from 'soapbox/hooks';
|
||||
import { compareId } from 'soapbox/utils/comparators';
|
||||
import { hueShift } from 'soapbox/utils/theme';
|
||||
|
||||
import Color from './color';
|
||||
|
||||
interface ColorGroup {
|
||||
[tint: string]: string,
|
||||
}
|
||||
|
||||
interface IPalette {
|
||||
palette: ColorGroup,
|
||||
onChange: (palette: ColorGroup) => void,
|
||||
resetKey?: string,
|
||||
}
|
||||
|
||||
/** Editable color palette. */
|
||||
const Palette: React.FC<IPalette> = ({ palette, onChange, resetKey }) => {
|
||||
const tints = Object.keys(palette).sort(compareId);
|
||||
|
||||
const [hue, setHue] = useState(0);
|
||||
const lastHue = usePrevious(hue);
|
||||
|
||||
const handleChange = (tint: string) => {
|
||||
return (color: string) => {
|
||||
onChange({
|
||||
...palette,
|
||||
[tint]: color,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const delta = hue - (lastHue || 0);
|
||||
|
||||
const adjusted = Object.entries(palette).reduce<ColorGroup>((result, [tint, hex]) => {
|
||||
result[tint] = hueShift(hex, delta * 360);
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
onChange(adjusted);
|
||||
}, [hue]);
|
||||
|
||||
useEffect(() => {
|
||||
setHue(0);
|
||||
}, [resetKey]);
|
||||
|
||||
return (
|
||||
<Stack className='w-full'>
|
||||
<HStack className='h-8 rounded-md overflow-hidden'>
|
||||
{tints.map(tint => (
|
||||
<Color color={palette[tint]} onChange={handleChange(tint)} />
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
<Slider value={hue} onChange={setHue} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
Palette as default,
|
||||
ColorGroup,
|
||||
};
|
|
@ -0,0 +1,273 @@
|
|||
import React, { useRef, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { updateSoapboxConfig } from 'soapbox/actions/admin';
|
||||
import { getHost } from 'soapbox/actions/instance';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import { fetchSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { Button, Column, Form, FormActions } from 'soapbox/components/ui';
|
||||
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
|
||||
import ColorWithPicker from 'soapbox/features/soapbox-config/components/color-with-picker';
|
||||
import { useAppDispatch, useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
|
||||
import { download } from 'soapbox/utils/download';
|
||||
|
||||
import Palette, { ColorGroup } from './components/palette';
|
||||
|
||||
import type { ColorChangeHandler } from 'react-color';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'admin.theme.title', defaultMessage: 'Theme' },
|
||||
saved: { id: 'theme_editor.saved', defaultMessage: 'Theme updated!' },
|
||||
restore: { id: 'theme_editor.restore', defaultMessage: 'Restore default theme' },
|
||||
export: { id: 'theme_editor.export', defaultMessage: 'Export theme' },
|
||||
import: { id: 'theme_editor.import', defaultMessage: 'Import theme' },
|
||||
importSuccess: { id: 'theme_editor.import_success', defaultMessage: 'Theme was successfully imported!' },
|
||||
});
|
||||
|
||||
interface IThemeEditor {
|
||||
}
|
||||
|
||||
/** UI for editing Tailwind theme colors. */
|
||||
const ThemeEditor: React.FC<IThemeEditor> = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const soapbox = useSoapboxConfig();
|
||||
const host = useAppSelector(state => getHost(state));
|
||||
const rawConfig = useAppSelector(state => state.soapbox);
|
||||
|
||||
const [colors, setColors] = useState(soapbox.colors.toJS() as any);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [resetKey, setResetKey] = useState(uuidv4());
|
||||
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const updateColors = (key: string) => {
|
||||
return (newColors: ColorGroup) => {
|
||||
setColors({
|
||||
...colors,
|
||||
[key]: {
|
||||
...colors[key],
|
||||
...newColors,
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const updateColor = (key: string) => {
|
||||
return (hex: string) => {
|
||||
setColors({
|
||||
...colors,
|
||||
[key]: hex,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const setTheme = (theme: any) => {
|
||||
setResetKey(uuidv4());
|
||||
setTimeout(() => setColors(theme));
|
||||
};
|
||||
|
||||
const resetTheme = () => {
|
||||
setTheme(soapbox.colors.toJS() as any);
|
||||
};
|
||||
|
||||
const updateTheme = async () => {
|
||||
const params = rawConfig.set('colors', colors).toJS();
|
||||
await dispatch(updateSoapboxConfig(params));
|
||||
};
|
||||
|
||||
const restoreDefaultTheme = () => {
|
||||
const colors = normalizeSoapboxConfig({ brandColor: '#0482d8' }).colors.toJS();
|
||||
setTheme(colors);
|
||||
};
|
||||
|
||||
const exportTheme = () => {
|
||||
const data = JSON.stringify(colors, null, 2);
|
||||
download(data, 'theme.json');
|
||||
};
|
||||
|
||||
const importTheme = () => {
|
||||
fileInput.current?.click();
|
||||
};
|
||||
|
||||
const handleSelectFile: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
|
||||
const file = e.target.files?.item(0);
|
||||
|
||||
if (file) {
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text);
|
||||
const colors = normalizeSoapboxConfig({ colors: json }).colors.toJS();
|
||||
|
||||
setTheme(colors);
|
||||
dispatch(snackbar.success(intl.formatMessage(messages.importSuccess)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async() => {
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
await dispatch(fetchSoapboxConfig(host));
|
||||
await updateTheme();
|
||||
dispatch(snackbar.success(intl.formatMessage(messages.saved)));
|
||||
setSubmitting(false);
|
||||
} catch (e) {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<List>
|
||||
<PaletteListItem
|
||||
label='Primary'
|
||||
palette={colors.primary}
|
||||
onChange={updateColors('primary')}
|
||||
resetKey={resetKey}
|
||||
/>
|
||||
|
||||
<PaletteListItem
|
||||
label='Secondary'
|
||||
palette={colors.secondary}
|
||||
onChange={updateColors('secondary')}
|
||||
resetKey={resetKey}
|
||||
/>
|
||||
|
||||
<PaletteListItem
|
||||
label='Accent'
|
||||
palette={colors.accent}
|
||||
onChange={updateColors('accent')}
|
||||
resetKey={resetKey}
|
||||
/>
|
||||
|
||||
<PaletteListItem
|
||||
label='Gray'
|
||||
palette={colors.gray}
|
||||
onChange={updateColors('gray')}
|
||||
resetKey={resetKey}
|
||||
/>
|
||||
|
||||
<PaletteListItem
|
||||
label='Success'
|
||||
palette={colors.success}
|
||||
onChange={updateColors('success')}
|
||||
resetKey={resetKey}
|
||||
/>
|
||||
|
||||
<PaletteListItem
|
||||
label='Danger'
|
||||
palette={colors.danger}
|
||||
onChange={updateColors('danger')}
|
||||
resetKey={resetKey}
|
||||
/>
|
||||
</List>
|
||||
|
||||
<List>
|
||||
<ColorListItem
|
||||
label='Greentext'
|
||||
value={colors.greentext}
|
||||
onChange={updateColor('greentext')}
|
||||
/>
|
||||
|
||||
<ColorListItem
|
||||
label='Accent Blue'
|
||||
value={colors['accent-blue']}
|
||||
onChange={updateColor('accent-blue')}
|
||||
/>
|
||||
|
||||
<ColorListItem
|
||||
label='Gradient Start'
|
||||
value={colors['gradient-start']}
|
||||
onChange={updateColor('gradient-start')}
|
||||
/>
|
||||
|
||||
<ColorListItem
|
||||
label='Gradient End'
|
||||
value={colors['gradient-end']}
|
||||
onChange={updateColor('gradient-end')}
|
||||
/>
|
||||
</List>
|
||||
|
||||
<FormActions>
|
||||
<DropdownMenuContainer
|
||||
items={[{
|
||||
text: intl.formatMessage(messages.restore),
|
||||
action: restoreDefaultTheme,
|
||||
icon: require('@tabler/icons/refresh.svg'),
|
||||
},{
|
||||
text: intl.formatMessage(messages.import),
|
||||
action: importTheme,
|
||||
icon: require('@tabler/icons/upload.svg'),
|
||||
}, {
|
||||
text: intl.formatMessage(messages.export),
|
||||
action: exportTheme,
|
||||
icon: require('@tabler/icons/download.svg'),
|
||||
}]}
|
||||
/>
|
||||
<Button theme='secondary' onClick={resetTheme}>
|
||||
<FormattedMessage id='theme_editor.Reset' defaultMessage='Reset' />
|
||||
</Button>
|
||||
|
||||
<Button type='submit' theme='primary' disabled={submitting}>
|
||||
<FormattedMessage id='theme_editor.save' defaultMessage='Save theme' />
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
|
||||
<input
|
||||
type='file'
|
||||
ref={fileInput}
|
||||
multiple
|
||||
accept='application/json'
|
||||
className='hidden'
|
||||
onChange={handleSelectFile}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
interface IPaletteListItem {
|
||||
label: React.ReactNode,
|
||||
palette: ColorGroup,
|
||||
onChange: (palette: ColorGroup) => void,
|
||||
resetKey?: string,
|
||||
}
|
||||
|
||||
/** Palette editor inside a ListItem. */
|
||||
const PaletteListItem: React.FC<IPaletteListItem> = ({ label, palette, onChange, resetKey }) => {
|
||||
return (
|
||||
<ListItem label={<div className='w-20'>{label}</div>}>
|
||||
<Palette palette={palette} onChange={onChange} resetKey={resetKey} />
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
interface IColorListItem {
|
||||
label: React.ReactNode,
|
||||
value: string,
|
||||
onChange: (hex: string) => void,
|
||||
}
|
||||
|
||||
/** Single-color picker. */
|
||||
const ColorListItem: React.FC<IColorListItem> = ({ label, value, onChange }) => {
|
||||
const handleChange: ColorChangeHandler = (color, _e) => {
|
||||
onChange(color.hex);
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem label={label}>
|
||||
<ColorWithPicker
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
className='w-10 h-8 rounded-md overflow-hidden'
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeEditor;
|
|
@ -45,7 +45,7 @@ class Bundle extends React.PureComponent<BundleProps, BundleState> {
|
|||
this.load(this.props);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps: BundleProps) {
|
||||
UNSAFE_componentWillReceiveProps(nextProps: BundleProps) {
|
||||
if (nextProps.fetchComponent !== this.props.fetchComponent) {
|
||||
this.load(nextProps);
|
||||
}
|
||||
|
|
|
@ -108,6 +108,7 @@ import {
|
|||
TestTimeline,
|
||||
LogoutPage,
|
||||
AuthTokenList,
|
||||
ThemeEditor,
|
||||
Quotes,
|
||||
ServiceWorkerInfo,
|
||||
EventInformation,
|
||||
|
@ -312,6 +313,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
|
|||
<WrappedRoute path='/soapbox/admin/reports' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
|
||||
<WrappedRoute path='/soapbox/admin/log' staffOnly page={AdminPage} component={ModerationLog} content={children} exact />
|
||||
<WrappedRoute path='/soapbox/admin/users' staffOnly page={AdminPage} component={UserIndex} content={children} exact />
|
||||
<WrappedRoute path='/soapbox/admin/theme' staffOnly page={AdminPage} component={ThemeEditor} content={children} exact />
|
||||
<WrappedRoute path='/info' page={EmptyPage} component={ServerInfo} content={children} />
|
||||
|
||||
<WrappedRoute path='/developers/apps/create' developerOnly page={DefaultPage} component={CreateApp} content={children} />
|
||||
|
|
|
@ -310,6 +310,10 @@ export function ModerationLog() {
|
|||
return import(/* webpackChunkName: "features/admin/moderation_log" */'../../admin/moderation-log');
|
||||
}
|
||||
|
||||
export function ThemeEditor() {
|
||||
return import(/* webpackChunkName: "features/theme-editor" */'../../theme-editor');
|
||||
}
|
||||
|
||||
export function UserPanel() {
|
||||
return import(/* webpackChunkName: "features/ui" */'../components/user-panel');
|
||||
}
|
||||
|
|
|
@ -1,221 +0,0 @@
|
|||
# Custom Locale Data
|
||||
|
||||
This folder is used to store custom locale data. These custom locale data are
|
||||
not yet provided by [Unicode Common Locale Data Repository](http://cldr.unicode.org/development/new-cldr-developers)
|
||||
and hence not provided in [react-intl/locale-data/*](https://github.com/yahoo/react-intl).
|
||||
|
||||
The locale data should support [Locale Data APIs](https://github.com/yahoo/react-intl/wiki/API#locale-data-apis)
|
||||
of the react-intl library.
|
||||
|
||||
It is recommended to start your custom locale data from this sample English
|
||||
locale data ([*](#plural-rules)):
|
||||
|
||||
```javascript
|
||||
/*eslint eqeqeq: "off"*/
|
||||
/*eslint no-nested-ternary: "off"*/
|
||||
|
||||
export default [
|
||||
{
|
||||
locale: "en",
|
||||
pluralRuleFunction: function(e, a) {
|
||||
var n = String(e).split("."),
|
||||
l = !n[1],
|
||||
o = Number(n[0]) == e,
|
||||
t = o && n[0].slice(-1),
|
||||
r = o && n[0].slice(-2);
|
||||
return a ? 1 == t && 11 != r ? "one" : 2 == t && 12 != r ? "two" : 3 == t && 13 != r ? "few" : "other" : 1 == e && l ? "one" : "other"
|
||||
},
|
||||
fields: {
|
||||
year: {
|
||||
displayName: "year",
|
||||
relative: {
|
||||
0: "this year",
|
||||
1: "next year",
|
||||
"-1": "last year"
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} year",
|
||||
other: "in {0} years"
|
||||
},
|
||||
past: {
|
||||
one: "{0} year ago",
|
||||
other: "{0} years ago"
|
||||
}
|
||||
}
|
||||
},
|
||||
month: {
|
||||
displayName: "month",
|
||||
relative: {
|
||||
0: "this month",
|
||||
1: "next month",
|
||||
"-1": "last month"
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} month",
|
||||
other: "in {0} months"
|
||||
},
|
||||
past: {
|
||||
one: "{0} month ago",
|
||||
other: "{0} months ago"
|
||||
}
|
||||
}
|
||||
},
|
||||
day: {
|
||||
displayName: "day",
|
||||
relative: {
|
||||
0: "today",
|
||||
1: "tomorrow",
|
||||
"-1": "yesterday"
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} day",
|
||||
other: "in {0} days"
|
||||
},
|
||||
past: {
|
||||
one: "{0} day ago",
|
||||
other: "{0} days ago"
|
||||
}
|
||||
}
|
||||
},
|
||||
hour: {
|
||||
displayName: "hour",
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} hour",
|
||||
other: "in {0} hours"
|
||||
},
|
||||
past: {
|
||||
one: "{0} hour ago",
|
||||
other: "{0} hours ago"
|
||||
}
|
||||
}
|
||||
},
|
||||
minute: {
|
||||
displayName: "minute",
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} minute",
|
||||
other: "in {0} minutes"
|
||||
},
|
||||
past: {
|
||||
one: "{0} minute ago",
|
||||
other: "{0} minutes ago"
|
||||
}
|
||||
}
|
||||
},
|
||||
second: {
|
||||
displayName: "second",
|
||||
relative: {
|
||||
0: "now"
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} second",
|
||||
other: "in {0} seconds"
|
||||
},
|
||||
past: {
|
||||
one: "{0} second ago",
|
||||
other: "{0} seconds ago"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
### Plural Rules
|
||||
|
||||
The function `pluralRuleFunction()` should return the key to proper string of
|
||||
a plural form(s). The purpose of the function is to provide key of translate
|
||||
strings of correct plural form according. The different forms are described in
|
||||
[CLDR's Plural Rules][cldr-plural-rules],
|
||||
|
||||
[cldr-plural-rules]: http://cldr.unicode.org/index/cldr-spec/plural-rules
|
||||
|
||||
#### Quick Overview on CLDR Rules
|
||||
|
||||
Let's take English as an example.
|
||||
|
||||
When you describe a number, you can be either describe it as:
|
||||
* Cardinals: 1st, 2nd, 3rd ... 11th, 12th ... 21st, 22nd, 23nd ....
|
||||
* Ordinals: 1, 2, 3 ...
|
||||
|
||||
In any of these cases, the nouns will reflect the number with singular or plural
|
||||
form. For example:
|
||||
* in 0 days
|
||||
* in 1 day
|
||||
* in 2 days
|
||||
|
||||
The `pluralRuleFunction` receives 2 parameters:
|
||||
* `e`: a string representation of the number. Such as, "`1`", "`2`", "`2.1`".
|
||||
* `a`: `true` if this is "cardinal" type of description. `false` for ordinal and other case.
|
||||
|
||||
#### How you should write `pluralRuleFunction`
|
||||
|
||||
The first rule to write pluralRuleFunction is never translate the output string
|
||||
into your language. [Plural Rules][cldr-plural-rules] specified you should use
|
||||
these as the return values:
|
||||
|
||||
* "`zero`"
|
||||
* "`one`" (singular)
|
||||
* "`two`" (dual)
|
||||
* "`few`" (paucal)
|
||||
* "`many`" (also used for fractions if they have a separate class)
|
||||
* "`other`" (required—general plural form—also used if the language only has a single form)
|
||||
|
||||
Again, we'll use English as the example here.
|
||||
|
||||
Let's read the `return` statement in the pluralRuleFunction above:
|
||||
```javascript
|
||||
return a ? 1 == t && 11 != r ? "one" : 2 == t && 12 != r ? "two" : 3 == t && 13 != r ? "few" : "other" : 1 == e && l ? "one" : "other"
|
||||
```
|
||||
|
||||
This nested ternary is hard to read. It basically means:
|
||||
```javascript
|
||||
// e: the number variable to examine
|
||||
// a: "true" if cardinals
|
||||
// l: "true" if the variable e has nothin after decimal mark (e.g. "1.0" would be false)
|
||||
// o: "true" if the variable e is an integer
|
||||
// t: the "ones" of the number. e.g. "3" for number "9123"
|
||||
// r: the "ones" and "tens" of the number. e.g. "23" for number "9123"
|
||||
if (a == true) {
|
||||
if (t == 1 && r != 11) {
|
||||
return "one"; // i.e. 1st, 21st, 101st, 121st ...
|
||||
} else if (t == 2 && r != 12) {
|
||||
return "two"; // i.e. 2nd, 22nd, 102nd, 122nd ...
|
||||
} else if (t == 3 && r != 13) {
|
||||
return "few"; // i.e. 3rd, 23rd, 103rd, 123rd ...
|
||||
} else {
|
||||
return "other"; // i.e. 4th, 11th, 12th, 24th ...
|
||||
}
|
||||
} else {
|
||||
if (e == 1 && l) {
|
||||
return "one"; // i.e. 1 day
|
||||
} else {
|
||||
return "other"; // i.e. 0 days, 2 days, 3 days
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If your language, like French, do not have complicated cardinal rules, you may
|
||||
use the French's version of it:
|
||||
```javascript
|
||||
function (e, a) {
|
||||
return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";
|
||||
}
|
||||
```
|
||||
|
||||
If your language, like Chinese, do not have any pluralization rule at all you
|
||||
may use the Chinese's version of it:
|
||||
```javascript
|
||||
function (e, a) {
|
||||
return "other";
|
||||
}
|
||||
```
|
|
@ -1,108 +0,0 @@
|
|||
/*eslint eqeqeq: "off"*/
|
||||
/*eslint no-nested-ternary: "off"*/
|
||||
/*eslint quotes: "off"*/
|
||||
|
||||
export default [{
|
||||
locale: "co",
|
||||
pluralRuleFunction: function(e, a) {
|
||||
return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";
|
||||
},
|
||||
fields: {
|
||||
year: {
|
||||
displayName: "annu",
|
||||
relative: {
|
||||
0: "quist'annu",
|
||||
1: "l'annu chì vene",
|
||||
"-1": "l'annu passatu",
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} annu",
|
||||
other: "in {0} anni",
|
||||
},
|
||||
past: {
|
||||
one: "{0} annu fà",
|
||||
other: "{0} anni fà",
|
||||
},
|
||||
},
|
||||
},
|
||||
month: {
|
||||
displayName: "mese",
|
||||
relative: {
|
||||
0: "Questu mese",
|
||||
1: "u mese chì vene",
|
||||
"-1": "u mese passatu",
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} mese",
|
||||
other: "in {0} mesi",
|
||||
},
|
||||
past: {
|
||||
one: "{0} mese fà",
|
||||
other: "{0} mesi fà",
|
||||
},
|
||||
},
|
||||
},
|
||||
day: {
|
||||
displayName: "ghjornu",
|
||||
relative: {
|
||||
0: "oghje",
|
||||
1: "dumane",
|
||||
"-1": "eri",
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} ghjornu",
|
||||
other: "in {0} ghjornu",
|
||||
},
|
||||
past: {
|
||||
one: "{0} ghjornu fà",
|
||||
other: "{0} ghjorni fà",
|
||||
},
|
||||
},
|
||||
},
|
||||
hour: {
|
||||
displayName: "ora",
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} ora",
|
||||
other: "in {0} ore",
|
||||
},
|
||||
past: {
|
||||
one: "{0} ora fà",
|
||||
other: "{0} ore fà",
|
||||
},
|
||||
},
|
||||
},
|
||||
minute: {
|
||||
displayName: "minuta",
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} minuta",
|
||||
other: "in {0} minute",
|
||||
},
|
||||
past: {
|
||||
one: "{0} minuta fà",
|
||||
other: "{0} minute fà",
|
||||
},
|
||||
},
|
||||
},
|
||||
second: {
|
||||
displayName: "siconda",
|
||||
relative: {
|
||||
0: "avà",
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "in {0} siconda",
|
||||
other: "in {0} siconde",
|
||||
},
|
||||
past: {
|
||||
one: "{0} siconda fà",
|
||||
other: "{0} siconde fà",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}];
|
|
@ -1,108 +0,0 @@
|
|||
/*eslint eqeqeq: "off"*/
|
||||
/*eslint no-nested-ternary: "off"*/
|
||||
/*eslint quotes: "off"*/
|
||||
|
||||
export default [{
|
||||
locale: "oc",
|
||||
pluralRuleFunction: function(e, a) {
|
||||
return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";
|
||||
},
|
||||
fields: {
|
||||
year: {
|
||||
displayName: "an",
|
||||
relative: {
|
||||
0: "ongan",
|
||||
1: "l'an que ven",
|
||||
"-1": "l'an passat",
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "d’aquí {0} an",
|
||||
other: "d’aquí {0} ans",
|
||||
},
|
||||
past: {
|
||||
one: "fa {0} an",
|
||||
other: "fa {0} ans",
|
||||
},
|
||||
},
|
||||
},
|
||||
month: {
|
||||
displayName: "mes",
|
||||
relative: {
|
||||
0: "aqueste mes",
|
||||
1: "lo mes que ven",
|
||||
"-1": "lo mes passat",
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "d’aquí {0} mes",
|
||||
other: "d’aquí {0} meses",
|
||||
},
|
||||
past: {
|
||||
one: "fa {0} mes",
|
||||
other: "fa {0} meses",
|
||||
},
|
||||
},
|
||||
},
|
||||
day: {
|
||||
displayName: "jorn",
|
||||
relative: {
|
||||
0: "uèi",
|
||||
1: "deman",
|
||||
"-1": "ièr",
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "d’aquí {0} jorn",
|
||||
other: "d’aquí {0} jorns",
|
||||
},
|
||||
past: {
|
||||
one: "fa {0} jorn",
|
||||
other: "fa {0} jorns",
|
||||
},
|
||||
},
|
||||
},
|
||||
hour: {
|
||||
displayName: "ora",
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "d’aquí {0} ora",
|
||||
other: "d’aquí {0} oras",
|
||||
},
|
||||
past: {
|
||||
one: "fa {0} ora",
|
||||
other: "fa {0} oras",
|
||||
},
|
||||
},
|
||||
},
|
||||
minute: {
|
||||
displayName: "minuta",
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "d’aquí {0} minuta",
|
||||
other: "d’aquí {0} minutas",
|
||||
},
|
||||
past: {
|
||||
one: "fa {0} minuta",
|
||||
other: "fa {0} minutas",
|
||||
},
|
||||
},
|
||||
},
|
||||
second: {
|
||||
displayName: "segonda",
|
||||
relative: {
|
||||
0: "ara",
|
||||
},
|
||||
relativeTime: {
|
||||
future: {
|
||||
one: "d’aquí {0} segonda",
|
||||
other: "d’aquí {0} segondas",
|
||||
},
|
||||
past: {
|
||||
one: "fa {0} segonda",
|
||||
other: "fa {0} segondas",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}];
|
|
@ -43,7 +43,6 @@ const DEFAULT_COLORS = ImmutableMap<string, any>({
|
|||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
}),
|
||||
'sea-blue': '#2feecc',
|
||||
'greentext': '#789922',
|
||||
});
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import { ADMIN_LOG_FETCH_SUCCESS } from 'soapbox/actions/admin';
|
|||
import type { AnyAction } from 'redux';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
const LogEntryRecord = ImmutableRecord({
|
||||
export const LogEntryRecord = ImmutableRecord({
|
||||
data: ImmutableMap<string, any>(),
|
||||
id: 0,
|
||||
message: '',
|
||||
|
|
|
@ -26,10 +26,12 @@ import {
|
|||
StatusRecord,
|
||||
TagRecord,
|
||||
} from 'soapbox/normalizers';
|
||||
import { LogEntryRecord } from 'soapbox/reducers/admin-log';
|
||||
|
||||
import type { Record as ImmutableRecord } from 'immutable';
|
||||
|
||||
type AdminAccount = ReturnType<typeof AdminAccountRecord>;
|
||||
type AdminLog = ReturnType<typeof LogEntryRecord>;
|
||||
type AdminReport = ReturnType<typeof AdminReportRecord>;
|
||||
type Announcement = ReturnType<typeof AnnouncementRecord>;
|
||||
type AnnouncementReaction = ReturnType<typeof AnnouncementReactionRecord>;
|
||||
|
@ -72,6 +74,7 @@ type EmbeddedEntity<T extends object> = null | string | ReturnType<ImmutableReco
|
|||
|
||||
export {
|
||||
AdminAccount,
|
||||
AdminLog,
|
||||
AdminReport,
|
||||
Account,
|
||||
Announcement,
|
||||
|
|
|
@ -3,6 +3,8 @@ const { execSync } = require('child_process');
|
|||
|
||||
const pkg = require('../../../package.json');
|
||||
|
||||
const { CI_COMMIT_TAG, CI_COMMIT_REF_NAME, CI_COMMIT_SHA } = process.env;
|
||||
|
||||
const shortRepoName = url => new URL(url).pathname.substring(1);
|
||||
const trimHash = hash => hash.substring(0, 7);
|
||||
|
||||
|
@ -10,14 +12,12 @@ const tryGit = cmd => {
|
|||
try {
|
||||
return String(execSync(cmd));
|
||||
} catch (e) {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const version = pkg => {
|
||||
// Try to discern from GitLab CI first
|
||||
const { CI_COMMIT_TAG, CI_COMMIT_REF_NAME, CI_COMMIT_SHA } = process.env;
|
||||
|
||||
if (CI_COMMIT_TAG === `v${pkg.version}` || CI_COMMIT_REF_NAME === 'stable') {
|
||||
return pkg.version;
|
||||
}
|
||||
|
@ -43,4 +43,5 @@ module.exports = {
|
|||
repository: shortRepoName(pkg.repository.url),
|
||||
version: version(pkg),
|
||||
homepage: pkg.homepage,
|
||||
ref: CI_COMMIT_TAG || CI_COMMIT_SHA || tryGit('git rev-parse HEAD'),
|
||||
};
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import type { AxiosResponse } from 'axios';
|
||||
|
||||
/** Download the file from the response instead of opening it in a tab. */
|
||||
// https://stackoverflow.com/a/53230807
|
||||
export const download = (response: AxiosResponse, filename: string) => {
|
||||
const url = URL.createObjectURL(new Blob([response.data]));
|
||||
export const download = (data: string, filename: string): void => {
|
||||
const url = URL.createObjectURL(new Blob([data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
|
|
|
@ -116,3 +116,18 @@ export const colorsToCss = (colors: TailwindColorPalette): string => {
|
|||
export const generateThemeCss = (soapboxConfig: SoapboxConfig): string => {
|
||||
return colorsToCss(soapboxConfig.colors.toJS() as TailwindColorPalette);
|
||||
};
|
||||
|
||||
const hexToHsl = (hex: string): Hsl | null => {
|
||||
const rgb = hexToRgb(hex);
|
||||
return rgb ? rgbToHsl(rgb) : null;
|
||||
};
|
||||
|
||||
export const hueShift = (hex: string, delta: number): string => {
|
||||
const { h, s, l } = hexToHsl(hex)!;
|
||||
|
||||
return hslToHex({
|
||||
h: (h + delta) % 360,
|
||||
s,
|
||||
l,
|
||||
});
|
||||
};
|
|
@ -48,7 +48,6 @@
|
|||
@import 'components/audio-player';
|
||||
@import 'components/filters';
|
||||
@import 'components/snackbar';
|
||||
@import 'components/admin';
|
||||
@import 'components/backups';
|
||||
@import 'components/crypto-donate';
|
||||
@import 'components/aliases';
|
||||
|
|
|
@ -1,67 +0,0 @@
|
|||
.dashcounters {
|
||||
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mb-4;
|
||||
}
|
||||
|
||||
.dashcounter {
|
||||
@apply bg-gray-200 dark:bg-gray-800 p-4 rounded flex flex-col items-center space-y-2 hover:-translate-y-1 transition-transform cursor-pointer;
|
||||
}
|
||||
|
||||
.dashwidgets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -5px;
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
|
||||
.dashwidget {
|
||||
flex: 1;
|
||||
margin-bottom: 20px;
|
||||
padding: 0 5px;
|
||||
|
||||
h4 {
|
||||
text-transform: uppercase;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: hsla(var(--primary-text-color_hsl), 0.6);
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--accent-color--med);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--brand-color);
|
||||
}
|
||||
}
|
||||
|
||||
.unapproved-account {
|
||||
padding: 15px 20px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
|
||||
&__nickname {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
column-gap: 10px;
|
||||
padding-left: 20px;
|
||||
|
||||
.svg-icon {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logentry {
|
||||
padding: 15px;
|
||||
|
||||
&__timestamp {
|
||||
color: var(--primary-text-color--faint);
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
|
@ -70,7 +70,6 @@ body,
|
|||
--dark-blue: #1d1953;
|
||||
--electric-blue: #5448ee;
|
||||
--electric-blue-contrast: #e8e7fd;
|
||||
--sea-blue: #2feecc;
|
||||
|
||||
// Sizes
|
||||
--border-radius-base: 4px;
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import { danger, warn, message } from 'danger';
|
||||
|
||||
// App changes
|
||||
const app = danger.git.fileMatch('app/soapbox/**');
|
||||
|
||||
// Docs changes
|
||||
const docs = danger.git.fileMatch('docs/**/*.md');
|
||||
|
||||
|
@ -7,6 +10,13 @@ if (docs.edited) {
|
|||
message('Thanks - We :heart: our [documentarians](http://www.writethedocs.org/)!');
|
||||
}
|
||||
|
||||
// Enforce CHANGELOG.md additions
|
||||
const changelog = danger.git.fileMatch('CHANGELOG.md');
|
||||
|
||||
if (app.edited && !changelog.edited) {
|
||||
warn('You have not updated `CHANGELOG.md`. If this change directly impacts admins or users, please update the changelog. Otherwise you can ignore this message. See: https://keepachangelog.com');
|
||||
}
|
||||
|
||||
// UI components
|
||||
const uiCode = danger.git.fileMatch('app/soapbox/components/ui/**');
|
||||
const uiTests = danger.git.fileMatch('app/soapbox/components/ui/**/__tests__/**');
|
||||
|
|
|
@ -21,7 +21,6 @@ module.exports = {
|
|||
'app/soapbox/**/*.ts',
|
||||
'app/soapbox/**/*.tsx',
|
||||
'!app/soapbox/features/emoji/emoji-compressed.js',
|
||||
'!app/soapbox/locales/locale-data/*.js',
|
||||
'!app/soapbox/service-worker/entry.ts',
|
||||
'!app/soapbox/jest/test-setup.ts',
|
||||
'!app/soapbox/jest/test-helpers.ts',
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 512 KiB After Width: | Height: | Size: 469 KiB |
|
@ -58,7 +58,6 @@ module.exports = {
|
|||
'accent-blue': true,
|
||||
'gradient-start': true,
|
||||
'gradient-end': true,
|
||||
'sea-blue': true,
|
||||
'greentext': true,
|
||||
}),
|
||||
animation: {
|
||||
|
|
|
@ -42,12 +42,10 @@ describe('parseColorMatrix()', () => {
|
|||
accent: [300, 500],
|
||||
'gradient-start': true,
|
||||
'gradient-end': true,
|
||||
'sea-blue': true,
|
||||
};
|
||||
|
||||
const result = parseColorMatrix(colorMatrix);
|
||||
|
||||
expect(result['sea-blue']({})).toEqual('rgb(var(--color-sea-blue))');
|
||||
expect(result['gradient-start']({ opacityValue: .7 })).toEqual('rgb(var(--color-gradient-start) / 0.7)');
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue