Theme editor See merge request soapbox-pub/soapbox!1895environments/review-develop-3zknud/deployments/1790
commit
493d608076
@ -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;
|
@ -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;
|
Loading…
Reference in new issue