From 12f0d635d79a0418ba490b0122359f3ac1d23f4e Mon Sep 17 00:00:00 2001 From: Damir Date: Fri, 13 Nov 2026 17:33:00 +0000 Subject: [PATCH] 7 files: Make Appearance modal --- .../addons/paint-gradient-maker/userscript.js | 14 +- src/components/dash-appearance/appearance.css | 188 +++++++ src/components/dash-appearance/appearance.jsx | 496 ++++++++++++++++++ src/lib/gradient-maker.js | 23 + .../generated-translations.json | 21 + src/playground/user/user.css | 5 + src/playground/user/user.jsx | 90 +++- 7 files changed, 801 insertions(+), 36 deletions(-) create mode 100644 src/components/dash-appearance/appearance.css create mode 100644 src/components/dash-appearance/appearance.jsx create mode 100644 src/lib/gradient-maker.js 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} +
+
+ + + + + {custom && ( +
+
+ {parts.map((part, index) => ( +
+
+ + + +
+
+ + +
+
+ )} +
+
+ + + + {listStatus === 'loading' && ( +

+ +

+ )} + {listStatus === 'error' && ( + + )} +
+ {options.map(option => ( + + ))} +
+
+ {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 &&