[Glitch] Add language picker to server rules section

Port d78535eab9 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Echo
2025-05-27 15:57:34 +02:00
committed by Claire
parent 086a7016c4
commit ebab5e42c0
6 changed files with 258 additions and 63 deletions

View File

@@ -18,7 +18,7 @@ export interface SelectItem {
icon?: string;
iconComponent?: IconProp;
text: string;
meta: string;
meta?: string;
extra?: string;
}

View File

@@ -0,0 +1,156 @@
import { useCallback, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react';
import type { IntlShape } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { createSelector } from '@reduxjs/toolkit';
import type { List as ImmutableList } from 'immutable';
import type { SelectItem } from '@/flavours/glitch/components/dropdown_selector';
import type { RootState } from '@/flavours/glitch/store';
import { useAppSelector } from '@/flavours/glitch/store';
import { Section } from './section';
const messages = defineMessages({
rules: { id: 'about.rules', defaultMessage: 'Server rules' },
defaultLocale: { id: 'about.default_locale', defaultMessage: 'Default' },
});
interface RulesSectionProps {
isLoading?: boolean;
}
interface BaseRule {
text: string;
hint: string;
}
interface Rule extends BaseRule {
id: string;
translations: Record<string, BaseRule>;
}
export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
const intl = useIntl();
const [locale, setLocale] = useState(intl.locale);
const rules = useAppSelector((state) => rulesSelector(state, locale));
const localeOptions = useAppSelector((state) =>
localeOptionsSelector(state, intl),
);
const handleLocaleChange: ChangeEventHandler<HTMLSelectElement> = useCallback(
(e) => {
setLocale(e.currentTarget.value);
},
[],
);
if (isLoading) {
return <Section title={intl.formatMessage(messages.rules)} />;
}
if (rules.length === 0) {
return (
<Section title={intl.formatMessage(messages.rules)}>
<p>
<FormattedMessage
id='about.not_available'
defaultMessage='This information has not been made available on this server.'
/>
</p>
</Section>
);
}
return (
<Section title={intl.formatMessage(messages.rules)}>
<ol className='rules-list'>
{rules.map((rule) => (
<li key={rule.id}>
<div className='rules-list__text'>{rule.text}</div>
{!!rule.hint && <div className='rules-list__hint'>{rule.hint}</div>}
</li>
))}
</ol>
<div className='rules-languages'>
<label htmlFor='language-select'>
<FormattedMessage
id='about.language_label'
defaultMessage='Language'
/>
</label>
<select onChange={handleLocaleChange} id='language-select'>
{localeOptions.map((option) => (
<option
key={option.value}
value={option.value}
selected={option.value === locale}
>
{option.text}
</option>
))}
</select>
</div>
</Section>
);
};
const selectRules = (state: RootState) => {
const rules = state.server.getIn([
'server',
'rules',
]) as ImmutableList<Rule> | null;
if (!rules) {
return [];
}
return rules.toJS() as Rule[];
};
const rulesSelector = createSelector(
[selectRules, (_state, locale: string) => locale],
(rules, locale): Rule[] => {
return rules.map((rule) => {
const translations = rule.translations;
if (translations[locale]) {
rule.text = translations[locale].text;
rule.hint = translations[locale].hint;
}
const partialLocale = locale.split('-')[0];
if (partialLocale && translations[partialLocale]) {
rule.text = translations[partialLocale].text;
rule.hint = translations[partialLocale].hint;
}
return rule;
});
},
);
const localeOptionsSelector = createSelector(
[selectRules, (_state, intl: IntlShape) => intl],
(rules, intl): SelectItem[] => {
const langs: Record<string, SelectItem> = {
default: {
value: 'default',
text: intl.formatMessage(messages.defaultLocale),
},
};
// Use the default locale as a target to translate language names.
const intlLocale = new Intl.DisplayNames(intl.locale, {
type: 'language',
});
for (const { translations } of rules) {
for (const locale in translations) {
if (langs[locale]) {
continue; // Skip if already added
}
langs[locale] = {
value: locale,
text: intlLocale.of(locale) ?? locale,
};
}
}
return Object.values(langs);
},
);

View File

@@ -0,0 +1,45 @@
import type { FC, MouseEventHandler } from 'react';
import { useCallback, useState } from 'react';
import classNames from 'classnames';
import { Icon } from '@/flavours/glitch/components/icon';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import ExpandMoreIcon from '@/material-icons/400-24px/expand_more.svg?react';
interface SectionProps {
title: string;
children?: React.ReactNode;
open?: boolean;
onOpen?: () => void;
}
export const Section: FC<SectionProps> = ({
title,
children,
open = false,
onOpen,
}) => {
const [collapsed, setCollapsed] = useState(!open);
const handleClick: MouseEventHandler = useCallback(() => {
setCollapsed((prev) => !prev);
onOpen?.();
}, [onOpen]);
return (
<div className={classNames('about__section', { active: !collapsed })}>
<button
className='about__section__title'
tabIndex={0}
onClick={handleClick}
>
<Icon
id={collapsed ? 'chevron-right' : 'chevron-down'}
icon={collapsed ? ChevronRightIcon : ExpandMoreIcon}
/>{' '}
{title}
</button>
{!collapsed && <div className='about__section__body'>{children}</div>}
</div>
);
};

View File

@@ -3,26 +3,23 @@ import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { List as ImmutableList } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import ExpandMoreIcon from '@/material-icons/400-24px/expand_more.svg?react';
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'flavours/glitch/actions/server';
import { Account } from 'flavours/glitch/components/account';
import Column from 'flavours/glitch/components/column';
import { Icon } from 'flavours/glitch/components/icon';
import { ServerHeroImage } from 'flavours/glitch/components/server_hero_image';
import { Skeleton } from 'flavours/glitch/components/skeleton';
import { LinkFooter} from 'flavours/glitch/features/ui/components/link_footer';
import { Section } from './components/section';
import { RulesSection } from './components/rules';
const messages = defineMessages({
title: { id: 'column.about', defaultMessage: 'About' },
rules: { id: 'about.rules', defaultMessage: 'Server rules' },
blocks: { id: 'about.blocks', defaultMessage: 'Moderated servers' },
silenced: { id: 'about.domain_blocks.silenced.title', defaultMessage: 'Limited' },
silencedExplanation: { id: 'about.domain_blocks.silenced.explanation', defaultMessage: 'You will generally not see profiles and content from this server, unless you explicitly look it up or opt into it by following.' },
@@ -49,45 +46,6 @@ const mapStateToProps = state => ({
domainBlocks: state.getIn(['server', 'domainBlocks']),
});
class Section extends PureComponent {
static propTypes = {
title: PropTypes.string,
children: PropTypes.node,
open: PropTypes.bool,
onOpen: PropTypes.func,
};
state = {
collapsed: !this.props.open,
};
handleClick = () => {
const { onOpen } = this.props;
const { collapsed } = this.state;
this.setState({ collapsed: !collapsed }, () => onOpen && onOpen());
};
render () {
const { title, children } = this.props;
const { collapsed } = this.state;
return (
<div className={classNames('about__section', { active: !collapsed })}>
<div className='about__section__title' role='button' tabIndex={0} onClick={this.handleClick}>
<Icon id={collapsed ? 'chevron-right' : 'chevron-down'} icon={collapsed ? ChevronRightIcon : ExpandMoreIcon} /> {title}
</div>
{!collapsed && (
<div className='about__section__body'>{children}</div>
)}
</div>
);
}
}
class About extends PureComponent {
static propTypes = {
@@ -165,23 +123,7 @@ class About extends PureComponent {
))}
</Section>
<Section title={intl.formatMessage(messages.rules)}>
{!isLoading && (server.get('rules', ImmutableList()).isEmpty() ? (
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
) : (
<ol className='rules-list'>
{server.get('rules').map(rule => {
const text = rule.getIn(['translations', locale, 'text']) || rule.getIn(['translations', locale.split('-')[0], 'text']) || rule.get('text');
const hint = rule.getIn(['translations', locale, 'hint']) || rule.getIn(['translations', locale.split('-')[0], 'hint']) || rule.get('hint');
return (
<li key={rule.get('id')}>
<div className='rules-list__text'>{text}</div>
{hint.length > 0 && (<div className='rules-list__hint'>{hint}</div>)}
</li>
)})}
</ol>
))}
</Section>
<RulesSection />
<Section title={intl.formatMessage(messages.blocks)} onOpen={this.handleDomainBlocksOpen}>
{domainBlocks.get('isLoading') ? (

View File

@@ -1,4 +1,5 @@
@use 'variables' as *;
@use 'functions' as *;
$maximum-width: 1235px;
$fluid-breakpoint: $maximum-width + 20px;
@@ -93,3 +94,52 @@ $fluid-breakpoint: $maximum-width + 20px;
color: $darker-text-color;
}
}
.rules-languages {
display: flex;
gap: 1rem;
align-items: center;
position: relative;
> label {
font-size: 14px;
font-weight: 600;
color: $primary-text-color;
}
> select {
appearance: none;
box-sizing: border-box;
font-size: 14px;
color: $primary-text-color;
display: block;
width: 100%;
outline: 0;
font-family: inherit;
resize: vertical;
background: $ui-base-color;
border: 1px solid var(--background-border-color);
border-radius: 4px;
padding-inline-start: 10px;
padding-inline-end: 30px;
height: 41px;
@media screen and (width <= 600px) {
font-size: 16px;
}
}
&::after {
display: block;
position: absolute;
width: 15px;
height: 15px;
content: '';
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14.933' height='18.467' viewBox='0 0 14.933 18.467'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='currentColor' /></svg>")
no-repeat 50% 50%;
mask-size: contain;
right: 8px;
background-color: lighten($ui-base-color, 12%);
pointer-events: none;
}
}

View File

@@ -10157,6 +10157,8 @@ noscript {
border: 1px solid var(--background-border-color);
color: $highlight-text-color;
cursor: pointer;
width: 100%;
background: none;
}
&.active &__title {