diff --git a/src/addons/addons/paint-gradient-maker/userscript.js b/src/addons/addons/paint-gradient-maker/userscript.js
index 774ebf82d..27826c336 100644
--- a/src/addons/addons/paint-gradient-maker/userscript.js
+++ b/src/addons/addons/paint-gradient-maker/userscript.js
@@ -1,3 +1,5 @@
+import {encodeGradHTML, pointerPercent} from "../../../lib/gradient-maker";
+
// Gradient Maker Addon
// Original by: SharkPool
export default async function ({ addon, console, msg }) {
@@ -35,15 +37,6 @@ export default async function ({ addon, console, msg }) {
}
}
- function encodeGradHTML(settings) {
- const sortedParts = [...settings.parts].sort((a, b) => a.p - b.p);
-
- let gradString = settings.type === "Linear" ? "linear-gradient(" : "radial-gradient(";
- if (settings.type === "Linear") gradString += `${settings.dir}deg, `;
- for (const part of sortedParts) gradString += `${part.c} ${part.p}%, `;
- return gradString.substring(0, gradString.length - 2) + ")";
- }
-
function genLinearGradPoints(bounds, angleDeg) {
const center = bounds.center;
const dir = new paper.Point({ angle: angleDeg, length: 1 });
@@ -395,8 +388,7 @@ export default async function ({ addon, console, msg }) {
const containerRect = container.getBoundingClientRect();
const onMouseMove = (moveEvent) => {
- const x = moveEvent.clientX - containerRect.left;
- const percent = Math.min(100, Math.max(0, (x / container.offsetWidth) * 100));
+ const percent = pointerPercent(moveEvent.clientX, containerRect.left, container.offsetWidth);
draggable.style.left = `${percent}%`;
if (percent > 50) {
opacityInput.style.removeProperty("left");
diff --git a/src/components/dash-appearance/appearance.css b/src/components/dash-appearance/appearance.css
new file mode 100644
index 000000000..b4ac1245c
--- /dev/null
+++ b/src/components/dash-appearance/appearance.css
@@ -0,0 +1,188 @@
+@import "../../css/colors.css";
+@import "../../css/units.css";
+
+.modal.modal {
+ width: min(36rem, calc(100vw - 1.5rem));
+ margin: min(8vh, 4rem) auto;
+}
+
+.body {
+ padding: clamp(0.875rem, 4vw, 2rem);
+ max-height: calc(84vh - 3.5rem);
+ overflow: auto;
+ background: $ui-modal-background;
+ color: $ui-modal-foreground;
+}
+
+.preview, .row, .toggle, .actions {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.preview {
+ padding: 1rem 1.25rem;
+ overflow-wrap: anywhere;
+ background-color: $ui-tertiary;
+ border: 1px solid $ui-black-transparent;
+ border-radius: $space;
+ font-size: 1.125rem;
+}
+
+.large-avatar, .small-avatar {
+ flex-shrink: 0;
+ width: 4.5rem;
+ height: 4.5rem;
+}
+
+.small-avatar {
+ width: 3rem;
+ height: 3rem;
+}
+
+.avatar {
+ border-radius: $space;
+ object-fit: cover;
+}
+
+.body fieldset {
+ min-width: 0;
+ margin: 1.25rem 0 0;
+ padding: 0;
+ border: 0;
+}
+
+.body legend {
+ margin-bottom: 0.65rem;
+ padding: 0;
+ font-weight: bold;
+}
+
+.toggle, .row label, .tile {
+ font-size: 0.875rem;
+}
+
+.editor {
+ padding: 0.75rem;
+ margin-top: 0.5rem;
+ border: 1px solid $ui-black-transparent;
+ border-radius: $space;
+}
+
+.rail {
+ position: relative;
+ height: 2.25rem;
+ margin: 0.35rem 0.65rem 1.2rem;
+ border-radius: 0.25rem;
+}
+
+.body button,
+.support-button {
+ padding: 0.5rem 0.75rem;
+ font: inherit;
+ font-size: 0.875rem;
+ font-weight: bold;
+ border: 1px solid $ui-black-transparent;
+ border-radius: 0.25rem;
+ background: $ui-tertiary;
+ color: $text-primary;
+ cursor: pointer;
+}
+
+.body button.handle {
+ position: absolute;
+ bottom: -0.55rem;
+ width: 1.35rem;
+ height: 1.35rem;
+ padding: 0;
+ transform: translateX(-50%);
+ border: 2px solid white;
+ box-shadow: 0 0 0 1px $ui-black-transparent;
+ touch-action: none;
+ cursor: ew-resize;
+}
+
+.handle[aria-pressed="true"] {
+ z-index: 1;
+ outline: 2px solid $looks-secondary;
+}
+
+.row {
+ flex-wrap: wrap;
+ margin-top: 0.65rem;
+}
+
+.row label {
+ display: grid;
+ flex: 1 1 5.5rem;
+ min-width: 0;
+ gap: 0.25rem;
+}
+
+.row input {
+ width: 100%;
+ accent-color: $looks-secondary;
+}
+
+.frames {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(5.5rem, 1fr));
+ gap: 0.5rem;
+}
+
+.frame-card {
+ position: relative;
+ cursor: pointer;
+}
+
+.frame-card input {
+ position: absolute;
+ opacity: 0;
+}
+
+.tile {
+ display: flex;
+ height: 100%;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.75rem 0.25rem;
+ text-align: center;
+ overflow-wrap: anywhere;
+ border: 2px solid $ui-black-transparent;
+ border-radius: $space;
+}
+
+.frame-card input:checked + .tile {
+ border-color: $looks-secondary;
+ background: $ui-tertiary;
+}
+
+.frame-card input:focus-visible + .tile,
+.body button:focus-visible,
+.support-button:focus-visible {
+ outline: 2px solid $looks-secondary;
+ outline-offset: 2px;
+}
+
+.actions {
+ justify-content: flex-end;
+ margin-top: 1.25rem;
+}
+
+.actions button:last-child,
+.actions .support-button {
+ background: $looks-secondary;
+ color: white;
+}
+
+.support-button {
+ text-align: center;
+ text-decoration: none;
+}
+
+.body button:disabled {
+ cursor: default;
+ opacity: 0.55;
+}
diff --git a/src/components/dash-appearance/appearance.jsx b/src/components/dash-appearance/appearance.jsx
new file mode 100644
index 000000000..ebcd3eaa1
--- /dev/null
+++ b/src/components/dash-appearance/appearance.jsx
@@ -0,0 +1,496 @@
+import bindAll from 'lodash.bindall';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {defineMessages, FormattedMessage, injectIntl, intlShape} from 'react-intl';
+
+import Modal from '../../containers/modal.jsx';
+import {requestDashApi} from '../../lib/dash-api';
+import {encodeGradHTML, pointerPercent, profileBackground, sortedParts} from '../../lib/gradient-maker';
+import FramedAvatar from '../dash-framed-avatar/framed-avatar.jsx';
+import * as styles from './appearance.css';
+
+const messages = defineMessages({
+ title: {
+ id: 'dash.appearance.title',
+ defaultMessage: 'Appearance',
+ description: 'Profile appearance'
+ },
+ point: {
+ id: 'dash.appearance.point',
+ defaultMessage: 'Color stop {number}',
+ description: 'Color stop selector'
+ }
+});
+
+const initialParts = gradient => (gradient ? gradient.stops.map(stop => ({
+ c: stop.color.length === 4 ? `#${stop.color.slice(1).split('')
+ .map(char => char + char)
+ .join('')}` : stop.color,
+ p: parseFloat(stop.position)
+})) : [{c: '#855cd6', p: 0}, {c: '#ffab19', p: 100}]);
+
+const makeGradient = ({custom, dir, parts}) => (custom ? {
+ type: 'linear',
+ angle: dir,
+ stops: sortedParts(parts).map(({c, p}) => ({color: c, position: `${p}%`}))
+} : null);
+
+const frameName = frame => {
+ switch (frame.id) {
+ case 'blocks':
+ return ();
+ case 'dashy':
+ return ();
+ case 'thorny-core':
+ return (
+
+ );
+ default:
+ return frame.name || frame.id.replace(/[-_]+/g, ' ').replace(/\b\w/g, char => char.toUpperCase());
+ }
+};
+
+class Appearance extends React.Component {
+ constructor (props) {
+ super(props);
+ const {gradient, avatarFrame} = props.user.profile;
+ this.state = {
+ custom: Boolean(gradient),
+ dir: gradient ? gradient.angle : 90,
+ parts: initialParts(gradient),
+ selected: 0,
+ frame: avatarFrame || null,
+ frames: [],
+ listStatus: 'loading',
+ error: null,
+ saving: false
+ };
+ this.savedGradient = JSON.stringify(makeGradient(this.state));
+ this.savedFrame = avatarFrame || null;
+ this.rail = React.createRef();
+ bindAll(this, [
+ 'handleLoadFrames',
+ 'handleToggleCustom',
+ 'handleSelectFrame',
+ 'handleSelectPoint',
+ 'handleDragStart',
+ 'handleDragMove',
+ 'handleSetColor',
+ 'handleSetPosition',
+ 'handleSetAngle',
+ 'handleAddPoint',
+ 'handleRemovePoint',
+ 'handleClose',
+ 'handleSave'
+ ]);
+ }
+ componentDidMount () {
+ this.mounted = true;
+ this.handleLoadFrames();
+ }
+ componentWillUnmount () {
+ this.mounted = false;
+ this.catalog.abort();
+ }
+ async handleLoadFrames () {
+ if (this.catalog) this.catalog.abort();
+ this.catalog = new AbortController();
+ this.setState({listStatus: 'loading'});
+ try {
+ const frames = [];
+ let total;
+ do {
+ // I know there isnt that much frames
+ const response = await requestDashApi(`/avatar-frames?limit=40&offset=${frames.length}`, {
+ signal: this.catalog.signal
+ });
+ if (!response.ok) throw new Error();
+ const data = await response.json();
+ if (!data.ok || !Array.isArray(data.avatarFrames)) throw new Error();
+ frames.push(...data.avatarFrames);
+ total = data.total;
+ if (!data.avatarFrames.length) break;
+ } while (frames.length < total);
+ if (this.mounted) this.setState({frames, listStatus: 'ready'});
+ } catch (error) {
+ if (this.mounted && error.name !== 'AbortError') this.setState({listStatus: 'error'});
+ }
+ }
+ handleToggleCustom (event) {
+ this.setState({custom: event.target.checked});
+ }
+ handleSelectFrame (event) {
+ this.setState({frame: event.currentTarget.value || null});
+ }
+ handleSelectPoint (event) {
+ this.setState({selected: Number(event.currentTarget.dataset.index)});
+ }
+ handleDragStart (event) {
+ if (event.button !== 0 || !event.isPrimary) return;
+ event.currentTarget.setPointerCapture(event.pointerId);
+ this.handleSelectPoint(event);
+ }
+ handleDragMove (event) {
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
+ const {left, width} = this.rail.current.getBoundingClientRect();
+ if (width) {
+ this.editPart(Number(event.currentTarget.dataset.index), {
+ p: Math.round(pointerPercent(event.clientX, left, width))
+ });
+ }
+ }
+ editPart (index, change) {
+ this.setState(previous => ({
+ parts: previous.parts.map((part, i) => (i === index ? {...part, ...change} : part))
+ }));
+ }
+ handleSetColor (event) {
+ this.editPart(this.state.selected, {c: event.target.value});
+ }
+ handleSetPosition (event) {
+ this.editPart(this.state.selected, {p: Number(event.target.value)});
+ }
+ handleSetAngle (event) {
+ this.setState({dir: Number(event.target.value)});
+ }
+ handleAddPoint () {
+ this.setState(({parts, selected}) => {
+ if (parts.length >= 6) return null;
+ const positions = [0, ...sortedParts(parts).map(part => part.p), 100];
+ let gap = 0;
+ for (let i = 1; i < positions.length - 1; i++) {
+ if (positions[i + 1] - positions[i] > positions[gap + 1] - positions[gap]) gap = i;
+ }
+ const p = Math.round((positions[gap] + positions[gap + 1]) / 2);
+ return {parts: [...parts, {c: parts[selected].c, p}], selected: parts.length};
+ });
+ }
+ handleRemovePoint () {
+ this.setState(({parts, selected}) => (parts.length > 2 ? {
+ parts: parts.filter((part, index) => index !== selected),
+ selected: Math.max(0, selected - 1)
+ } : null));
+ }
+ handleClose () {
+ if (!this.inFlight) this.props.onClose();
+ }
+ async handleSave () {
+ if (!this.props.canSave || this.inFlight) return;
+ this.inFlight = true;
+ this.setState({saving: true, error: null});
+ const gradient = makeGradient(this.state);
+ const frame = this.state.frame;
+ const changes = [];
+ if (JSON.stringify(gradient) !== this.savedGradient) changes.push(['set-gradient', {gradient}]);
+ if (frame !== this.savedFrame) changes.push(['set-avatar-frame', {avatarFrame: frame}]);
+ let applied = 0;
+ try {
+ for (const [endpoint, body] of changes) {
+ const response = await requestDashApi(`/users/${endpoint}`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(body)
+ });
+ const data = await response.json();
+ if (!response.ok || !data.ok || String(data.user?.id) !== String(this.props.user.id)) throw new Error();
+ if (!this.mounted) return;
+ if (endpoint === 'set-gradient') this.savedGradient = JSON.stringify(gradient);
+ else this.savedFrame = frame;
+ this.props.onSaved(data.user);
+ applied++;
+ }
+ if (this.mounted) this.props.onClose();
+ } catch {
+ if (this.mounted) this.setState({error: applied ? 'partial' : 'failed'});
+ } finally {
+ this.inFlight = false;
+ if (this.mounted) this.setState({saving: false});
+ }
+ }
+ render () {
+ const {avatarBackground, avatarSrc, intl, user} = this.props;
+ const {custom, dir, error, frame, frames, listStatus, parts, saving, selected} = this.state;
+ const dirty = this.savedFrame !== frame || this.savedGradient !== JSON.stringify(makeGradient(this.state));
+ const background = custom ? profileBackground(makeGradient(this.state)) : avatarBackground;
+ const options = [{id: ''}, ...frames];
+ if (this.savedFrame && !options.some(option => option.id === this.savedFrame)) {
+ options.push({id: this.savedFrame});
+ }
+ return (
+
+
+
+
+ {user.username}
+
+
+
+ {error && (
+
+ {error === 'partial' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ {this.props.canSave ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ );
+ }
+}
+
+Appearance.propTypes = {
+ avatarBackground: PropTypes.string,
+ avatarSrc: PropTypes.string.isRequired,
+ canSave: PropTypes.bool.isRequired,
+ intl: intlShape,
+ onClose: PropTypes.func.isRequired,
+ onSaved: PropTypes.func.isRequired,
+ user: PropTypes.object.isRequired
+};
+
+export default injectIntl(Appearance);
diff --git a/src/lib/gradient-maker.js b/src/lib/gradient-maker.js
new file mode 100644
index 000000000..80199756d
--- /dev/null
+++ b/src/lib/gradient-maker.js
@@ -0,0 +1,23 @@
+const sortedParts = parts => [...parts].sort((a, b) => a.p - b.p);
+
+const encodeGradHTML = ({type, dir, parts}) => {
+ const stops = sortedParts(parts).map(part => `${part.c} ${part.p}%`)
+ .join(', ');
+ return type === 'Linear' ? `linear-gradient(${dir}deg, ${stops})` : `radial-gradient(${stops})`;
+};
+
+const pointerPercent = (clientX, left, width) => Math.min(100, Math.max(0, ((clientX - left) / width) * 100));
+
+const profileBackground = gradient => {
+ if (!gradient || !gradient.stops.length) return null;
+ return encodeGradHTML({
+ type: 'Linear',
+ dir: gradient.angle,
+ parts: gradient.stops.map(stop => ({
+ p: parseFloat(stop.position),
+ c: `color-mix(in srgb, ${stop.color}, var(--ui-white) 60%)`
+ }))
+ });
+};
+
+export {encodeGradHTML, pointerPercent, profileBackground, sortedParts};
diff --git a/src/lib/tw-translations/generated-translations.json b/src/lib/tw-translations/generated-translations.json
index 1d3b30936..ddc084334 100644
--- a/src/lib/tw-translations/generated-translations.json
+++ b/src/lib/tw-translations/generated-translations.json
@@ -3259,6 +3259,27 @@
"tw.viewFeaturedProjects": "Vezi proiectele recomandate (dați click)."
},
"ru": {
+ "dash.appearance.title": "Оформление",
+ "dash.appearance.point": "Цветовая точка {number}",
+ "dash.appearance.blocks": "Блоки",
+ "dash.appearance.dashy": "Дэши",
+ "dash.appearance.thorny": "Шипастое ядро",
+ "dash.appearance.gradient": "Градиент профиля",
+ "dash.appearance.custom": "Использовать свой градиент",
+ "dash.appearance.color": "Цвет",
+ "dash.appearance.position": "Позиция: {position}%",
+ "dash.appearance.angle": "Угол: {angle}°",
+ "dash.appearance.add": "Добавить точку",
+ "dash.appearance.remove": "Удалить точку",
+ "dash.appearance.frames": "Рамка аватарки",
+ "dash.appearance.loading": "Загрузка рамок…",
+ "dash.appearance.retry": "Не удалось загрузить рамки. Попробовать ещё раз",
+ "dash.appearance.none": "Без рамки",
+ "dash.appearance.partial": "Градиент сохранён. Не удалось сохранить рамку. Попробуйте ещё раз.",
+ "dash.appearance.failed": "Не удалось сохранить оформление. Попробуйте ещё раз.",
+ "dash.appearance.cancel": "Отмена",
+ "dash.appearance.save": "Сохранить",
+ "dash.appearance.support": "Поддержите Dash, чтобы сохранить оформление",
"dash.collaboration.chat.title": "Чат",
"dash.collaboration.chat.heading": "Чат проекта",
"dash.collaboration.chat.close": "Закрыть",
diff --git a/src/playground/user/user.css b/src/playground/user/user.css
index 8d8b32da0..f989d5e8f 100644
--- a/src/playground/user/user.css
+++ b/src/playground/user/user.css
@@ -464,3 +464,8 @@ body {
text-overflow: ellipsis;
display: block;
}
+
+.appearance-button {
+ border-radius: 0.25rem;
+ cursor: pointer;
+}
diff --git a/src/playground/user/user.jsx b/src/playground/user/user.jsx
index 1a23be6b2..4795d47a0 100644
--- a/src/playground/user/user.jsx
+++ b/src/playground/user/user.jsx
@@ -24,6 +24,9 @@ import BufferedInputHOC from '../../components/forms/buffered-input-hoc.jsx';
import Input from '../../components/forms/input.jsx';
const BufferedInput = BufferedInputHOC(Input);
+import Appearance from '../../components/dash-appearance/appearance.jsx';
+import {profileBackground} from '../../lib/gradient-maker';
+import {setSession as setDashSession} from '../../reducers/dash';
import FramedAvatar from '../../components/dash-framed-avatar/framed-avatar.jsx';
import linkIcon from './icon--link.svg';
@@ -102,7 +105,8 @@ const User = props => {
const [actionsOffset, setActionsOffset] = useState(0);
const [actionsLoading, setActionsLoading] = useState(false);
- const [gradient, setGradient] = useState({});
+ const [avatarBackground, setAvatarBackground] = useState(null);
+ const [appearanceOpen, setAppearanceOpen] = useState(false);
const [isMyProfile, setIsMyProfile] = useState(false);
const [session, setSession] = useState(null);
@@ -112,6 +116,7 @@ const User = props => {
const fileInputRef = useRef(null);
useEffect(() => {
+ setAppearanceOpen(false);
const fetchFullProfile = async () => {
setLoading(true);
setError(null);
@@ -229,26 +234,26 @@ const User = props => {
};
};
- try {
- if (userData?.profile?.gradient) {
- setGradient({
- ...userData.profile.gradient,
- stops: userData.profile.gradient.stops.map(stop => ({
- color: `color-mix(in srgb, ${stop.color}, var(--ui-white) 60%)`,
- position: stop.position
- }))
- });
- } else {
- avgGradientByImgSections(
- `https://api.dashblocks.org/users/avatars/${userData?.profile?.avatarId}?t=${avatarCacheBuster}`,
- 5,
- 2
- ).then(avgGradient => setGradient(avgGradient));
- }
- } catch (_) {
- // Ignore errors
+ let active = true;
+ setAvatarBackground(null);
+ if (userData?.profile?.avatarId) {
+ avgGradientByImgSections(
+ `https://api.dashblocks.org/users/avatars/${userData.profile.avatarId}?t=${avatarCacheBuster}`,
+ 5,
+ 2
+ ).then(result => {
+ if (active) {
+ setAvatarBackground(`linear-gradient(${result.angle}deg, ${result.stops.map(stop =>
+ `${stop.color} ${stop.position}`
+ ).join(', ')})`);
+ }
+ })
+ .catch(() => {});
}
- }, [userData?.profile?.avatarId, userData?.profile?.gradient, avatarCacheBuster]);
+ return () => {
+ active = false;
+ };
+ }, [userData?.profile?.avatarId, avatarCacheBuster]);
const getAchievement = achievement => {
switch (achievement.type) {
@@ -680,6 +685,16 @@ const User = props => {
);
}
+ const canOpenAppearance = isMyProfile && Boolean(session?.id) && String(session.id) === String(userData.id);
+ const applyAppearance = updated => {
+ if (String(updated.id) !== String(userData.id)) return;
+ const changes = {gradient: updated.profile.gradient, avatarFrame: updated.profile.avatarFrame};
+ setUserData(previous => ({...previous, profile: {...previous.profile, ...changes}}));
+ const nextSession = {...session, profile: {...session.profile, ...changes}};
+ setSession(nextSession);
+ props.updateSession(nextSession);
+ };
+
const joinDate = userData.joinedAt ? new Date(userData.joinedAt) : null;
const lastActiveDate = userData.lastActive ? new Date(userData.lastActive) : null;
@@ -696,9 +711,7 @@ const User = props => {
`${stop.color} ${stop.position}`).join(', ')})`
- } : {}}
+ style={{backgroundImage: profileBackground(userData.profile.gradient) || avatarBackground}}
>
{
props.intl.formatMessage(messages.dasherRole)}
+ {canOpenAppearance && (
+
+ )}
+ {canOpenAppearance && appearanceOpen && (
+
setAppearanceOpen(false)}
+ onSaved={applyAppearance}
+ />
+ )}
{!isMyProfile &&