Merge branch 'theme-editor' into 'develop'
Theme editor See merge request soapbox-pub/soapbox!1895
This commit is contained in:
commit
493d608076
19 changed files with 553 additions and 59 deletions
|
@ -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.
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
124
app/soapbox/components/ui/slider/slider.tsx
Normal file
124
app/soapbox/components/ui/slider/slider.tsx
Normal file
|
@ -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;
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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(() => {});
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
28
app/soapbox/features/theme-editor/components/color.tsx
Normal file
28
app/soapbox/features/theme-editor/components/color.tsx
Normal file
|
@ -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;
|
67
app/soapbox/features/theme-editor/components/palette.tsx
Normal file
67
app/soapbox/features/theme-editor/components/palette.tsx
Normal file
|
@ -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,
|
||||
};
|
273
app/soapbox/features/theme-editor/index.tsx
Normal file
273
app/soapbox/features/theme-editor/index.tsx
Normal file
|
@ -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;
|
|
@ -106,6 +106,7 @@ import {
|
|||
TestTimeline,
|
||||
LogoutPage,
|
||||
AuthTokenList,
|
||||
ThemeEditor,
|
||||
Quotes,
|
||||
ServiceWorkerInfo,
|
||||
EventInformation,
|
||||
|
@ -297,6 +298,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');
|
||||
}
|
||||
|
|
|
@ -43,7 +43,6 @@ const DEFAULT_COLORS = ImmutableMap<string, any>({
|
|||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
}),
|
||||
'sea-blue': '#2feecc',
|
||||
'greentext': '#789922',
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
|
@ -70,7 +70,6 @@ body,
|
|||
--dark-blue: #1d1953;
|
||||
--electric-blue: #5448ee;
|
||||
--electric-blue-contrast: #e8e7fd;
|
||||
--sea-blue: #2feecc;
|
||||
|
||||
// Sizes
|
||||
--border-radius-base: 4px;
|
||||
|
|
|
@ -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 a new issue