diff --git a/CHANGELOG.md b/CHANGELOG.md index b17e16bf9..cc516b8df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. diff --git a/app/soapbox/actions/admin.ts b/app/soapbox/actions/admin.ts index 02af5e87a..3cd5a25ba 100644 --- a/app/soapbox/actions/admin.ts +++ b/app/soapbox/actions/admin.ts @@ -103,6 +103,19 @@ const updateConfig = (configs: Record[]) => }); }; +const updateSoapboxConfig = (data: Record) => + (dispatch: AppDispatch, _getState: () => RootState) => { + const params = [{ + group: ':pleroma', + key: ':frontend_configurations', + value: [{ + tuple: [':soapbox_fe', data], + }], + }]; + + return dispatch(updateConfig(params)); + }; + const fetchMastodonReports = (params: Record) => (dispatch: AppDispatch, getState: () => RootState) => api(getState) @@ -585,6 +598,7 @@ export { ADMIN_USERS_UNSUGGEST_FAIL, fetchConfig, updateConfig, + updateSoapboxConfig, fetchReports, closeReports, fetchUsers, diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts index 0dd6fff52..c3f148833 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -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'; diff --git a/app/soapbox/components/ui/slider/slider.tsx b/app/soapbox/components/ui/slider/slider.tsx new file mode 100644 index 000000000..009e7db5e --- /dev/null +++ b/app/soapbox/components/ui/slider/slider.tsx @@ -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 = ({ value, onChange }) => { + const node = useRef(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 ( +
+
+
+ +
+ ); +}; + +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; \ No newline at end of file diff --git a/app/soapbox/features/admin/tabs/dashboard.tsx b/app/soapbox/features/admin/tabs/dashboard.tsx index dd0f9f1bd..aa127f5a1 100644 --- a/app/soapbox/features/admin/tabs/dashboard.tsx +++ b/app/soapbox/features/admin/tabs/dashboard.tsx @@ -21,22 +21,22 @@ const Dashboard: React.FC = () => { 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(); }; diff --git a/app/soapbox/features/event/components/event-header.tsx b/app/soapbox/features/event/components/event-header.tsx index adfb2dc32..5e0226e2c 100644 --- a/app/soapbox/features/event/components/event-header.tsx +++ b/app/soapbox/features/event/components/event-header.tsx @@ -102,8 +102,8 @@ const EventHeader: React.FC = ({ status }) => { }; const handleExportClick = () => { - dispatch(fetchEventIcs(status.id)).then((response) => { - download(response, 'calendar.ics'); + dispatch(fetchEventIcs(status.id)).then(({ data }) => { + download(data, 'calendar.ics'); }).catch(() => {}); }; diff --git a/app/soapbox/features/soapbox-config/components/color-with-picker.tsx b/app/soapbox/features/soapbox-config/components/color-with-picker.tsx index c1c7dd3cd..be166cb9f 100644 --- a/app/soapbox/features/soapbox-config/components/color-with-picker.tsx +++ b/app/soapbox/features/soapbox-config/components/color-with-picker.tsx @@ -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 = ({ buttonId, value, onChange }) => { +const ColorWithPicker: React.FC = ({ value, onChange, className }) => { const node = useRef(null); const [active, setActive] = useState(false); const [placement, setPlacement] = useState(null); @@ -39,11 +39,10 @@ const ColorWithPicker: React.FC = ({ buttonId, value, onChange }; return ( -
+
}> + + + ); +}; + +interface IColorListItem { + label: React.ReactNode, + value: string, + onChange: (hex: string) => void, +} + +/** Single-color picker. */ +const ColorListItem: React.FC = ({ label, value, onChange }) => { + const handleChange: ColorChangeHandler = (color, _e) => { + onChange(color.hex); + }; + + return ( + + + + ); +}; + +export default ThemeEditor; \ No newline at end of file diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 115321a93..bc643efff 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -106,6 +106,7 @@ import { TestTimeline, LogoutPage, AuthTokenList, + ThemeEditor, Quotes, ServiceWorkerInfo, EventInformation, @@ -297,6 +298,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => { + diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index 396eea883..22a10543c 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -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'); } diff --git a/app/soapbox/normalizers/soapbox/soapbox-config.ts b/app/soapbox/normalizers/soapbox/soapbox-config.ts index c4246d8a0..b221afabe 100644 --- a/app/soapbox/normalizers/soapbox/soapbox-config.ts +++ b/app/soapbox/normalizers/soapbox/soapbox-config.ts @@ -43,7 +43,6 @@ const DEFAULT_COLORS = ImmutableMap({ 800: '#991b1b', 900: '#7f1d1d', }), - 'sea-blue': '#2feecc', 'greentext': '#789922', }); diff --git a/app/soapbox/utils/download.ts b/app/soapbox/utils/download.ts index c877bee25..f756490c9 100644 --- a/app/soapbox/utils/download.ts +++ b/app/soapbox/utils/download.ts @@ -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); diff --git a/app/soapbox/utils/theme.ts b/app/soapbox/utils/theme.ts index 4f6126c17..86b2c4a01 100644 --- a/app/soapbox/utils/theme.ts +++ b/app/soapbox/utils/theme.ts @@ -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, + }); +}; \ No newline at end of file diff --git a/app/styles/themes.scss b/app/styles/themes.scss index 97a9b5f95..5efd0fc97 100644 --- a/app/styles/themes.scss +++ b/app/styles/themes.scss @@ -70,7 +70,6 @@ body, --dark-blue: #1d1953; --electric-blue: #5448ee; --electric-blue-contrast: #e8e7fd; - --sea-blue: #2feecc; // Sizes --border-radius-base: 4px; diff --git a/tailwind.config.js b/tailwind.config.js index aa09fb24c..c57acc16e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -58,7 +58,6 @@ module.exports = { 'accent-blue': true, 'gradient-start': true, 'gradient-end': true, - 'sea-blue': true, 'greentext': true, }), animation: { diff --git a/tailwind/__tests__/colors-test.js b/tailwind/__tests__/colors-test.js index 89836edb7..36bc2526c 100644 --- a/tailwind/__tests__/colors-test.js +++ b/tailwind/__tests__/colors-test.js @@ -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)'); }); });