27
common/resources/client/i18n/change-locale.ts
Executable file
27
common/resources/client/i18n/change-locale.ts
Executable file
@@ -0,0 +1,27 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '../http/backend-response/backend-response';
|
||||
import {Localization} from './localization';
|
||||
import {apiClient} from '../http/query-client';
|
||||
import {showHttpErrorToast} from '../utils/http/show-http-error-toast';
|
||||
import {useBootstrapData} from '../core/bootstrap-data/bootstrap-data-context';
|
||||
|
||||
export interface ChangeLocaleResponse extends BackendResponse {
|
||||
locale: Localization;
|
||||
}
|
||||
|
||||
export function useChangeLocale() {
|
||||
const {mergeBootstrapData} = useBootstrapData();
|
||||
return useMutation({
|
||||
mutationFn: (props: {locale?: string}) => changeLocale(props),
|
||||
onSuccess: response => {
|
||||
mergeBootstrapData({
|
||||
i18n: response.locale,
|
||||
});
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function changeLocale(props: {locale?: string}): Promise<ChangeLocaleResponse> {
|
||||
return apiClient.post(`users/me/locale`, props).then(r => r.data);
|
||||
}
|
||||
17
common/resources/client/i18n/formatted-country-name.tsx
Executable file
17
common/resources/client/i18n/formatted-country-name.tsx
Executable file
@@ -0,0 +1,17 @@
|
||||
import {useSelectedLocale} from '@common/i18n/selected-locale';
|
||||
import {Fragment, memo} from 'react';
|
||||
|
||||
interface Props {
|
||||
code: string;
|
||||
}
|
||||
export const FormattedCountryName = memo(({code: countryCode}: Props) => {
|
||||
const {localeCode} = useSelectedLocale();
|
||||
const regionNames = new Intl.DisplayNames([localeCode], {type: 'region'});
|
||||
let formattedName: string | undefined;
|
||||
|
||||
try {
|
||||
formattedName = regionNames.of(countryCode.toUpperCase());
|
||||
} catch (e) {}
|
||||
|
||||
return <Fragment>{formattedName}</Fragment>;
|
||||
});
|
||||
22
common/resources/client/i18n/formatted-currency.tsx
Executable file
22
common/resources/client/i18n/formatted-currency.tsx
Executable file
@@ -0,0 +1,22 @@
|
||||
import {Fragment, memo} from 'react';
|
||||
import {useNumberFormatter} from './use-number-formatter';
|
||||
|
||||
interface FormattedCurrencyProps {
|
||||
value: number;
|
||||
currency: string;
|
||||
}
|
||||
export const FormattedCurrency = memo(
|
||||
({value, currency}: FormattedCurrencyProps) => {
|
||||
const formatter = useNumberFormatter({
|
||||
style: 'currency',
|
||||
currency,
|
||||
currencyDisplay: 'narrowSymbol',
|
||||
});
|
||||
|
||||
if (isNaN(value)) {
|
||||
value = 0;
|
||||
}
|
||||
|
||||
return <Fragment>{formatter.format(value)}</Fragment>;
|
||||
}
|
||||
);
|
||||
54
common/resources/client/i18n/formatted-date-time-range.tsx
Executable file
54
common/resources/client/i18n/formatted-date-time-range.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import {DateValue, parseAbsolute} from '@internationalized/date';
|
||||
import {Fragment, memo} from 'react';
|
||||
import {useDateFormatter} from './use-date-formatter';
|
||||
import {useSettings} from '../core/settings/use-settings';
|
||||
import {shallowEqual} from '../utils/shallow-equal';
|
||||
import {useUserTimezone} from './use-user-timezone';
|
||||
import {DateFormatPresets} from '@common/i18n/formatted-date';
|
||||
|
||||
interface FormattedDateTimeRangeProps {
|
||||
start?: string | DateValue | Date;
|
||||
end?: string | DateValue | Date;
|
||||
options?: Intl.DateTimeFormatOptions;
|
||||
preset?: keyof typeof DateFormatPresets;
|
||||
}
|
||||
export const FormattedDateTimeRange = memo(
|
||||
({start, end, options, preset}: FormattedDateTimeRangeProps) => {
|
||||
const {dates} = useSettings();
|
||||
const timezone = useUserTimezone();
|
||||
const formatter = useDateFormatter(
|
||||
options ||
|
||||
(DateFormatPresets as Record<string, Intl.DateTimeFormatOptions>)[
|
||||
preset || dates?.format
|
||||
]
|
||||
);
|
||||
|
||||
if (!start || !end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let value: string;
|
||||
|
||||
try {
|
||||
value = formatter.formatRange(
|
||||
castToDate(start, timezone),
|
||||
castToDate(end, timezone)
|
||||
);
|
||||
} catch (e) {
|
||||
value = '';
|
||||
}
|
||||
|
||||
return <Fragment>{value}</Fragment>;
|
||||
},
|
||||
shallowEqual
|
||||
);
|
||||
|
||||
function castToDate(date: string | DateValue | Date, timezone: string): Date {
|
||||
if (typeof date === 'string') {
|
||||
return parseAbsolute(date, timezone).toDate();
|
||||
}
|
||||
if ('toDate' in date) {
|
||||
return date.toDate(timezone);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
59
common/resources/client/i18n/formatted-date.tsx
Executable file
59
common/resources/client/i18n/formatted-date.tsx
Executable file
@@ -0,0 +1,59 @@
|
||||
import {DateValue, parseAbsoluteToLocal} from '@internationalized/date';
|
||||
import {Fragment, memo} from 'react';
|
||||
import {useDateFormatter} from './use-date-formatter';
|
||||
import {shallowEqual} from '../utils/shallow-equal';
|
||||
import {useSettings} from '../core/settings/use-settings';
|
||||
import {useUserTimezone} from './use-user-timezone';
|
||||
|
||||
export const DateFormatPresets: Record<
|
||||
'numeric' | 'short' | 'long' | 'timestamp',
|
||||
Intl.DateTimeFormatOptions
|
||||
> = {
|
||||
numeric: {year: 'numeric', month: '2-digit', day: '2-digit'},
|
||||
short: {year: 'numeric', month: 'short', day: '2-digit'},
|
||||
long: {month: 'long', day: '2-digit', year: 'numeric'},
|
||||
timestamp: {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
},
|
||||
};
|
||||
|
||||
interface FormattedDateProps {
|
||||
date?: string | DateValue | Date;
|
||||
options?: Intl.DateTimeFormatOptions;
|
||||
preset?: keyof typeof DateFormatPresets;
|
||||
}
|
||||
export const FormattedDate = memo(
|
||||
({date, options, preset}: FormattedDateProps) => {
|
||||
const {dates} = useSettings();
|
||||
const timezone = useUserTimezone();
|
||||
const formatter = useDateFormatter(
|
||||
options ||
|
||||
(DateFormatPresets as Record<string, Intl.DateTimeFormatOptions>)[
|
||||
preset || dates?.format
|
||||
],
|
||||
);
|
||||
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// make sure date with invalid format does not blow up the app
|
||||
try {
|
||||
if (typeof date === 'string') {
|
||||
date = parseAbsoluteToLocal(date).toDate();
|
||||
} else if ('toDate' in date) {
|
||||
date = date.toDate(timezone);
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Fragment>{formatter.format(date as Date)}</Fragment>;
|
||||
},
|
||||
shallowEqual,
|
||||
);
|
||||
100
common/resources/client/i18n/formatted-duration.tsx
Executable file
100
common/resources/client/i18n/formatted-duration.tsx
Executable file
@@ -0,0 +1,100 @@
|
||||
import {Fragment, memo} from 'react';
|
||||
import {useTrans, UseTransReturn} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
|
||||
interface ParsedMS {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
interface FormattedTrackDurationProps {
|
||||
ms?: number;
|
||||
minutes?: number;
|
||||
seconds?: number;
|
||||
verbose?: boolean;
|
||||
addZeroToFirstUnit?: boolean;
|
||||
}
|
||||
export const FormattedDuration = memo(
|
||||
({
|
||||
minutes,
|
||||
seconds,
|
||||
ms,
|
||||
verbose = false,
|
||||
addZeroToFirstUnit = true,
|
||||
}: FormattedTrackDurationProps) => {
|
||||
const {trans} = useTrans();
|
||||
|
||||
if (minutes) {
|
||||
ms = minutes * 60000;
|
||||
} else if (seconds) {
|
||||
ms = seconds * 1000;
|
||||
}
|
||||
if (!ms) {
|
||||
ms = 0;
|
||||
}
|
||||
|
||||
const unsignedMs = ms < 0 ? -ms : ms;
|
||||
const parsedMS: ParsedMS = {
|
||||
days: Math.trunc(unsignedMs / 86400000),
|
||||
hours: Math.trunc(unsignedMs / 3600000) % 24,
|
||||
minutes: Math.trunc(unsignedMs / 60000) % 60,
|
||||
seconds: Math.trunc(unsignedMs / 1000) % 60,
|
||||
};
|
||||
|
||||
let formattedValue: string;
|
||||
if (verbose) {
|
||||
formattedValue = formatVerbose(parsedMS, trans);
|
||||
} else {
|
||||
formattedValue = formatCompact(parsedMS, addZeroToFirstUnit);
|
||||
}
|
||||
|
||||
return <Fragment>{formattedValue}</Fragment>;
|
||||
}
|
||||
);
|
||||
|
||||
function formatVerbose(t: ParsedMS, trans: UseTransReturn['trans']) {
|
||||
const output: string[] = [];
|
||||
|
||||
if (t.days) {
|
||||
output.push(`${t.days}${trans(message('d'))}`);
|
||||
}
|
||||
if (t.hours) {
|
||||
output.push(`${t.hours}${trans(message('hr'))}`);
|
||||
}
|
||||
if (t.minutes) {
|
||||
output.push(`${t.minutes}${trans(message('min'))}`);
|
||||
}
|
||||
if (t.seconds && !t.hours) {
|
||||
output.push(`${t.seconds}${trans(message('sec'))}`);
|
||||
}
|
||||
|
||||
return output.join(' ');
|
||||
}
|
||||
|
||||
function formatCompact(t: ParsedMS, addZeroToFirstUnit = true) {
|
||||
const seconds = addZero(t.seconds);
|
||||
let output = '';
|
||||
if (t.days && !output) {
|
||||
output = `${t.days}:${addZero(t.hours)}:${addZero(t.minutes)}:${seconds}`;
|
||||
}
|
||||
if (t.hours && !output) {
|
||||
output = `${addZero(t.hours, addZeroToFirstUnit)}:${addZero(
|
||||
t.minutes
|
||||
)}:${seconds}`;
|
||||
}
|
||||
if (!output) {
|
||||
output = `${addZero(t.minutes, addZeroToFirstUnit)}:${seconds}`;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function addZero(v: number, addZero = true) {
|
||||
if (!addZero) return v;
|
||||
let value = `${v}`;
|
||||
if (value.length === 1) {
|
||||
value = '0' + value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
20
common/resources/client/i18n/formatted-number.tsx
Executable file
20
common/resources/client/i18n/formatted-number.tsx
Executable file
@@ -0,0 +1,20 @@
|
||||
import {Fragment, memo} from 'react';
|
||||
import {useNumberFormatter} from './use-number-formatter';
|
||||
import {NumberFormatOptions} from '@internationalized/number';
|
||||
import {shallowEqual} from '../utils/shallow-equal';
|
||||
|
||||
interface FormattedNumberProps extends NumberFormatOptions {
|
||||
value: number;
|
||||
}
|
||||
export const FormattedNumber = memo(
|
||||
({value, ...options}: FormattedNumberProps) => {
|
||||
const formatter = useNumberFormatter(options);
|
||||
|
||||
if (isNaN(value)) {
|
||||
value = 0;
|
||||
}
|
||||
|
||||
return <Fragment>{formatter.format(value)}</Fragment>;
|
||||
},
|
||||
shallowEqual
|
||||
);
|
||||
42
common/resources/client/i18n/formatted-price.tsx
Executable file
42
common/resources/client/i18n/formatted-price.tsx
Executable file
@@ -0,0 +1,42 @@
|
||||
import {FormattedCurrency} from './formatted-currency';
|
||||
import React from 'react';
|
||||
import {Price} from '../billing/price';
|
||||
import {Trans} from './trans';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface FormattedPriceProps {
|
||||
price?: Omit<Price, 'id'>;
|
||||
variant?: 'slash' | 'separateLine';
|
||||
className?: string;
|
||||
priceClassName?: string;
|
||||
periodClassName?: string;
|
||||
}
|
||||
export function FormattedPrice({
|
||||
price,
|
||||
variant = 'slash',
|
||||
className,
|
||||
priceClassName,
|
||||
periodClassName,
|
||||
}: FormattedPriceProps) {
|
||||
if (!price) return null;
|
||||
|
||||
const translatedInterval = <Trans message={price.interval} />;
|
||||
|
||||
return (
|
||||
<div className={clsx('flex gap-6 items-center', className)}>
|
||||
<div className={priceClassName}>
|
||||
<FormattedCurrency
|
||||
value={price.amount / (price.interval_count ?? 1)}
|
||||
currency={price.currency}
|
||||
/>
|
||||
</div>
|
||||
{variant === 'slash' ? (
|
||||
<div className={periodClassName}> / {translatedInterval}</div>
|
||||
) : (
|
||||
<div className={periodClassName}>
|
||||
<Trans message="per" /> <br /> {translatedInterval}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
common/resources/client/i18n/formatted-relative-time.tsx
Executable file
71
common/resources/client/i18n/formatted-relative-time.tsx
Executable file
@@ -0,0 +1,71 @@
|
||||
import {DateValue, parseAbsoluteToLocal} from '@internationalized/date';
|
||||
import {Fragment, memo, useMemo} from 'react';
|
||||
import {shallowEqual} from '@common/utils/shallow-equal';
|
||||
import {useSelectedLocale} from '@common/i18n/selected-locale';
|
||||
import {useUserTimezone} from '@common/i18n/use-user-timezone';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
|
||||
const DIVISIONS: {amount: number; name: Intl.RelativeTimeFormatUnit}[] = [
|
||||
{amount: 60, name: 'seconds'},
|
||||
{amount: 60, name: 'minutes'},
|
||||
{amount: 24, name: 'hours'},
|
||||
{amount: 7, name: 'days'},
|
||||
{amount: 4.34524, name: 'weeks'},
|
||||
{amount: 12, name: 'months'},
|
||||
{amount: Number.POSITIVE_INFINITY, name: 'years'},
|
||||
];
|
||||
|
||||
interface FormattedDateProps {
|
||||
date?: string | DateValue | Date;
|
||||
style?: Intl.RelativeTimeFormatStyle;
|
||||
}
|
||||
export const FormattedRelativeTime = memo(
|
||||
({date, style}: FormattedDateProps) => {
|
||||
const {localeCode} = useSelectedLocale();
|
||||
const timezone = useUserTimezone();
|
||||
|
||||
const formatter = useMemo(
|
||||
() =>
|
||||
new Intl.RelativeTimeFormat(localeCode, {
|
||||
numeric: 'auto',
|
||||
style,
|
||||
}),
|
||||
[localeCode, style]
|
||||
);
|
||||
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// make sure date with invalid format does not blow up the app
|
||||
try {
|
||||
if (typeof date === 'string') {
|
||||
date = parseAbsoluteToLocal(date).toDate();
|
||||
} else if ('toDate' in date) {
|
||||
date = date.toDate(timezone);
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let duration = (date.getTime() - Date.now()) / 1000;
|
||||
|
||||
for (let i = 0; i <= DIVISIONS.length; i++) {
|
||||
const division = DIVISIONS[i];
|
||||
if (Math.abs(duration) < division.amount) {
|
||||
if (division.name === 'seconds') {
|
||||
return <Trans message="a few seconds ago" />;
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
{formatter.format(Math.round(duration), division.name)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
duration /= division.amount;
|
||||
}
|
||||
|
||||
return <Fragment>{formatter.format(Math.round(duration), 'day')}</Fragment>;
|
||||
},
|
||||
shallowEqual
|
||||
);
|
||||
13
common/resources/client/i18n/get-user-timezone.ts
Executable file
13
common/resources/client/i18n/get-user-timezone.ts
Executable file
@@ -0,0 +1,13 @@
|
||||
import {getBootstrapData} from '../core/bootstrap-data/use-backend-bootstrap-data';
|
||||
import {getLocalTimeZone} from '@internationalized/date';
|
||||
|
||||
export function getUserTimezone(): string {
|
||||
const defaultTimezone = getBootstrapData()?.settings.dates.default_timezone;
|
||||
const preferredTimezone =
|
||||
getBootstrapData()?.user?.timezone || defaultTimezone || 'auto';
|
||||
|
||||
if (!preferredTimezone || preferredTimezone === 'auto') {
|
||||
return getLocalTimeZone();
|
||||
}
|
||||
return preferredTimezone;
|
||||
}
|
||||
40
common/resources/client/i18n/handle-plural-message.tsx
Executable file
40
common/resources/client/i18n/handle-plural-message.tsx
Executable file
@@ -0,0 +1,40 @@
|
||||
import memoize from 'nano-memoize';
|
||||
import {MessageDescriptor} from './message-descriptor';
|
||||
|
||||
// this will get memoized by enclosing function (<Trans> or useTrans)
|
||||
export function handlePluralMessage(
|
||||
localeCode: string,
|
||||
{message, values}: MessageDescriptor
|
||||
): string {
|
||||
// find plural config e.g. [one 1 item|other :count items]
|
||||
const match = message.match(/\[(.+?)]/);
|
||||
const count = values?.count;
|
||||
if (match && match[1] && !Number.isNaN(count)) {
|
||||
// get config without brackets and split by pipe e.g. [one 1 item, other :count items]
|
||||
const [pluralPlaceholder, pluralConfig] = match;
|
||||
const choices = pluralConfig.split('|');
|
||||
if (!choices.length) return message;
|
||||
|
||||
// use Intl.PluralRules to determine which choice to use, based on special "count" value
|
||||
const rules = getRules(localeCode);
|
||||
const choiceName = rules.select(count as number);
|
||||
|
||||
// find the correct choice from config, or use first one
|
||||
let choiceConfig = choices.find(c => {
|
||||
return c.startsWith(choiceName);
|
||||
});
|
||||
if (!choiceConfig) {
|
||||
choiceConfig = choices[0];
|
||||
}
|
||||
|
||||
// get rid of plural prefix e.g. one 1 item => 1 item
|
||||
const choice = choiceConfig.substring(choiceConfig.indexOf(' ') + 1);
|
||||
|
||||
return message.replace(pluralPlaceholder, choice);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
const getRules = memoize((localeCode: string) => {
|
||||
return new Intl.PluralRules(localeCode);
|
||||
});
|
||||
51
common/resources/client/i18n/locale-switcher.tsx
Executable file
51
common/resources/client/i18n/locale-switcher.tsx
Executable file
@@ -0,0 +1,51 @@
|
||||
import {useValueLists} from '../http/value-lists';
|
||||
import {Button} from '../ui/buttons/button';
|
||||
import {LanguageIcon} from '../icons/material/Language';
|
||||
import {KeyboardArrowDownIcon} from '../icons/material/KeyboardArrowDown';
|
||||
import {useSelectedLocale} from './selected-locale';
|
||||
import {Menu, MenuItem, MenuTrigger} from '../ui/navigation/menu/menu-trigger';
|
||||
import {useChangeLocale} from './change-locale';
|
||||
import {useSettings} from '../core/settings/use-settings';
|
||||
|
||||
export function LocaleSwitcher() {
|
||||
const {locale} = useSelectedLocale();
|
||||
const changeLocale = useChangeLocale();
|
||||
const {data} = useValueLists(['localizations']);
|
||||
const {i18n} = useSettings();
|
||||
|
||||
if (!data?.localizations || !locale || !i18n.enable) return null;
|
||||
|
||||
return (
|
||||
<MenuTrigger
|
||||
floatingWidth="matchTrigger"
|
||||
selectionMode="single"
|
||||
selectedValue={locale.language}
|
||||
onSelectionChange={value => {
|
||||
const newLocale = value as string;
|
||||
if (newLocale !== locale?.language) {
|
||||
changeLocale.mutate({locale: newLocale});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
disabled={changeLocale.isPending}
|
||||
className="capitalize"
|
||||
startIcon={<LanguageIcon />}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{locale.name}
|
||||
</Button>
|
||||
<Menu>
|
||||
{data.localizations.map(localization => (
|
||||
<MenuItem
|
||||
value={localization.language}
|
||||
key={localization.language}
|
||||
className="capitalize"
|
||||
>
|
||||
{localization.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
);
|
||||
}
|
||||
8
common/resources/client/i18n/localization.ts
Executable file
8
common/resources/client/i18n/localization.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
export interface Localization {
|
||||
id: number;
|
||||
name: string;
|
||||
language: string;
|
||||
lines?: Record<string, string>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
14
common/resources/client/i18n/message-descriptor.ts
Executable file
14
common/resources/client/i18n/message-descriptor.ts
Executable file
@@ -0,0 +1,14 @@
|
||||
import {ReactElement} from 'react';
|
||||
|
||||
export interface MessageDescriptor {
|
||||
message: string;
|
||||
values?: Record<
|
||||
string,
|
||||
| string
|
||||
| number
|
||||
| null
|
||||
| undefined
|
||||
| ReactElement
|
||||
| ((parts: string) => ReactElement)
|
||||
>;
|
||||
}
|
||||
6
common/resources/client/i18n/message.ts
Executable file
6
common/resources/client/i18n/message.ts
Executable file
@@ -0,0 +1,6 @@
|
||||
import {MessageDescriptor} from './message-descriptor';
|
||||
|
||||
interface MessageProps extends Omit<MessageDescriptor, 'message'> {}
|
||||
export function message(msg: string, props?: MessageProps): MessageDescriptor {
|
||||
return {...props, message: msg};
|
||||
}
|
||||
16
common/resources/client/i18n/mixed-text.tsx
Executable file
16
common/resources/client/i18n/mixed-text.tsx
Executable file
@@ -0,0 +1,16 @@
|
||||
import {MessageDescriptor} from './message-descriptor';
|
||||
import {Trans} from './trans';
|
||||
import {Fragment} from 'react';
|
||||
|
||||
interface Props {
|
||||
value?: string | MessageDescriptor | null;
|
||||
}
|
||||
export function MixedText({value}: Props) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return <Fragment>{value}</Fragment>;
|
||||
}
|
||||
return <Trans {...value} />;
|
||||
}
|
||||
12
common/resources/client/i18n/selected-locale.ts
Executable file
12
common/resources/client/i18n/selected-locale.ts
Executable file
@@ -0,0 +1,12 @@
|
||||
import {useBootstrapData} from '../core/bootstrap-data/bootstrap-data-context';
|
||||
|
||||
export function useSelectedLocale() {
|
||||
const {
|
||||
data: {i18n},
|
||||
} = useBootstrapData();
|
||||
return {
|
||||
locale: i18n,
|
||||
localeCode: i18n?.language || 'en',
|
||||
lines: i18n?.lines,
|
||||
};
|
||||
}
|
||||
120
common/resources/client/i18n/trans.tsx
Executable file
120
common/resources/client/i18n/trans.tsx
Executable file
@@ -0,0 +1,120 @@
|
||||
import {cloneElement, Fragment, isValidElement, memo} from 'react';
|
||||
import {shallowEqual} from '../utils/shallow-equal';
|
||||
import {useSelectedLocale} from './selected-locale';
|
||||
import {handlePluralMessage} from './handle-plural-message';
|
||||
import {MessageDescriptor} from './message-descriptor';
|
||||
|
||||
function hasOwn(obj: any, key: string): boolean {
|
||||
if (obj == null) {
|
||||
return false;
|
||||
}
|
||||
if (Object.hasOwn !== undefined) {
|
||||
return Object.hasOwn(obj, key);
|
||||
}
|
||||
return Object.hasOwnProperty(key);
|
||||
}
|
||||
|
||||
export const Trans = memo((props: MessageDescriptor) => {
|
||||
const {message: initialMessage, values} = props;
|
||||
const {lines, localeCode} = useSelectedLocale();
|
||||
let translatedMessage: string | undefined;
|
||||
|
||||
if (hasOwn(lines, initialMessage)) {
|
||||
translatedMessage = lines?.[initialMessage];
|
||||
} else if (hasOwn(lines, initialMessage?.toLowerCase())) {
|
||||
translatedMessage = lines?.[initialMessage.toLowerCase()];
|
||||
} else {
|
||||
translatedMessage = initialMessage;
|
||||
}
|
||||
|
||||
if (!values || !translatedMessage) {
|
||||
return <Fragment>{translatedMessage}</Fragment>;
|
||||
}
|
||||
|
||||
translatedMessage = handlePluralMessage(localeCode, {
|
||||
message: translatedMessage,
|
||||
values,
|
||||
});
|
||||
|
||||
// placeholders that need to be replaced with react element, eg. <Icon/>
|
||||
const nodePlaceholders: string[] = [];
|
||||
// placeholders that need to be replaced with render fn, eg. <a>link text</a>
|
||||
const tagNames: string[] = [];
|
||||
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
// value is react render function
|
||||
if (typeof value === 'function') {
|
||||
tagNames.push(key);
|
||||
// value is react element
|
||||
} else if (isValidElement(value)) {
|
||||
nodePlaceholders.push(key);
|
||||
// value is primitive, can do simple string replace
|
||||
} else if (value != undefined) {
|
||||
translatedMessage = translatedMessage?.replace(`:${key}`, `${value}`);
|
||||
}
|
||||
});
|
||||
|
||||
// if we need to replace placeholder with react element or render fn, we will need to split the
|
||||
// string by these placeholders and replace static string values with matching react element value
|
||||
if (tagNames.length || nodePlaceholders.length) {
|
||||
// we'll build simple OR regex to split the string eg. (<[ab]>content</[ab]>)|({(?:icon|link)})
|
||||
const regexArray: string[] = [];
|
||||
if (tagNames.length) {
|
||||
const tagNameMatchers = tagNames.join('');
|
||||
regexArray.push(`(<[${tagNameMatchers}]>.+?<\\/[${tagNameMatchers}]>)`);
|
||||
}
|
||||
if (nodePlaceholders.length) {
|
||||
const nodePlaceholderMatchers = nodePlaceholders.join('|');
|
||||
regexArray.push(`(\:(?:${nodePlaceholderMatchers}))`);
|
||||
}
|
||||
|
||||
const regex = new RegExp(regexArray.join('|'), 'gm');
|
||||
const parts = translatedMessage.split(regex);
|
||||
|
||||
// get rid of any empty strings or undefined from split by regex
|
||||
const compiledMessage = parts.filter(Boolean).map((part, i) => {
|
||||
// it's a tag name placeholder, eg. <a>content</a>
|
||||
if (part.startsWith('<') && part.endsWith('>')) {
|
||||
// grab tag content
|
||||
const matches = part.match(/<([a-z]+)>(.+?)<\/([a-z]+)>/);
|
||||
if (matches) {
|
||||
const [, tagName, content] = matches;
|
||||
const renderFn = values?.[tagName];
|
||||
if (typeof renderFn === 'function') {
|
||||
// pass it to render fn from values
|
||||
const node = renderFn(content);
|
||||
// add a key to avoid react errors
|
||||
return cloneElement(node, {key: i});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// it's a regular placeholder with react element value, eg. {icon}
|
||||
if (part.startsWith(':')) {
|
||||
const key = part.replace(':', '');
|
||||
const node = values?.[key];
|
||||
if (isValidElement(node)) {
|
||||
return cloneElement(node, {key: i});
|
||||
}
|
||||
}
|
||||
|
||||
// it's a regular string
|
||||
return part;
|
||||
});
|
||||
return <Fragment>{compiledMessage}</Fragment>;
|
||||
}
|
||||
|
||||
return <Fragment>{translatedMessage}</Fragment>;
|
||||
}, areEqual);
|
||||
|
||||
export function areEqual<T extends MessageDescriptor = MessageDescriptor>(
|
||||
prevProps: T,
|
||||
nextProps: T,
|
||||
): boolean {
|
||||
const {values, ...otherProps} = prevProps;
|
||||
const {values: nextValues, ...nextOtherProps} = nextProps;
|
||||
return (
|
||||
shallowEqual(nextValues, values) &&
|
||||
shallowEqual(otherProps as any, nextOtherProps)
|
||||
);
|
||||
}
|
||||
23
common/resources/client/i18n/use-collator.ts
Executable file
23
common/resources/client/i18n/use-collator.ts
Executable file
@@ -0,0 +1,23 @@
|
||||
import {useSelectedLocale} from './selected-locale';
|
||||
|
||||
const cache = new Map<string, Intl.Collator>();
|
||||
|
||||
export function useCollator(options?: Intl.CollatorOptions): Intl.Collator {
|
||||
const {localeCode} = useSelectedLocale();
|
||||
|
||||
const cacheKey =
|
||||
localeCode +
|
||||
(options
|
||||
? Object.entries(options)
|
||||
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||
.join()
|
||||
: '');
|
||||
|
||||
if (cache.has(cacheKey)) {
|
||||
return cache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const formatter = new Intl.Collator(localeCode, options);
|
||||
cache.set(cacheKey, formatter);
|
||||
return formatter;
|
||||
}
|
||||
10
common/resources/client/i18n/use-current-date-time.ts
Executable file
10
common/resources/client/i18n/use-current-date-time.ts
Executable file
@@ -0,0 +1,10 @@
|
||||
import {useMemo} from 'react';
|
||||
import {now} from '@internationalized/date';
|
||||
import {useUserTimezone} from './use-user-timezone';
|
||||
|
||||
export function useCurrentDateTime() {
|
||||
const timezone = useUserTimezone();
|
||||
return useMemo(() => {
|
||||
return now(timezone);
|
||||
}, [timezone]);
|
||||
}
|
||||
28
common/resources/client/i18n/use-date-formatter.ts
Executable file
28
common/resources/client/i18n/use-date-formatter.ts
Executable file
@@ -0,0 +1,28 @@
|
||||
import {DateFormatter} from '@internationalized/date';
|
||||
import {useMemo, useRef} from 'react';
|
||||
import {useSelectedLocale} from './selected-locale';
|
||||
import {shallowEqual} from '../utils/shallow-equal';
|
||||
|
||||
export function useDateFormatter(
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): DateFormatter {
|
||||
// Reuse last options object if it is shallowly equal, which allows the useMemo result to also be reused.
|
||||
const lastOptions = useRef<Intl.DateTimeFormatOptions | undefined | null>(
|
||||
null
|
||||
);
|
||||
if (
|
||||
options &&
|
||||
lastOptions.current &&
|
||||
shallowEqual(options as any, lastOptions.current)
|
||||
) {
|
||||
options = lastOptions.current;
|
||||
}
|
||||
|
||||
lastOptions.current = options;
|
||||
|
||||
const {localeCode} = useSelectedLocale();
|
||||
return useMemo(
|
||||
() => new DateFormatter(localeCode, options),
|
||||
[localeCode, options]
|
||||
);
|
||||
}
|
||||
59
common/resources/client/i18n/use-filter.ts
Executable file
59
common/resources/client/i18n/use-filter.ts
Executable file
@@ -0,0 +1,59 @@
|
||||
import {useCollator} from './use-collator';
|
||||
|
||||
interface Filter {
|
||||
/** Returns whether a string starts with a given substring. */
|
||||
startsWith(string: string, substring: string): boolean;
|
||||
/** Returns whether a string ends with a given substring. */
|
||||
endsWith(string: string, substring: string): boolean;
|
||||
/** Returns whether a string contains a given substring. */
|
||||
contains(string: string, substring: string): boolean;
|
||||
}
|
||||
|
||||
export function useFilter(options?: Intl.CollatorOptions): Filter {
|
||||
const collator = useCollator({
|
||||
usage: 'search',
|
||||
...options,
|
||||
});
|
||||
|
||||
return {
|
||||
startsWith(string, substring) {
|
||||
if (substring.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
string = string.normalize('NFC');
|
||||
substring = substring.normalize('NFC');
|
||||
return (
|
||||
collator.compare(string.slice(0, substring.length), substring) === 0
|
||||
);
|
||||
},
|
||||
endsWith(string, substring) {
|
||||
if (substring.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
string = string.normalize('NFC');
|
||||
substring = substring.normalize('NFC');
|
||||
return collator.compare(string.slice(-substring.length), substring) === 0;
|
||||
},
|
||||
contains(string, substring) {
|
||||
if (substring.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
string = string.normalize('NFC');
|
||||
substring = substring.normalize('NFC');
|
||||
|
||||
let scan = 0;
|
||||
const sliceLen = substring.length;
|
||||
for (; scan + sliceLen <= string.length; scan++) {
|
||||
const slice = string.slice(scan, scan + sliceLen);
|
||||
if (collator.compare(substring, slice) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
13
common/resources/client/i18n/use-number-formatter.ts
Executable file
13
common/resources/client/i18n/use-number-formatter.ts
Executable file
@@ -0,0 +1,13 @@
|
||||
import {NumberFormatOptions, NumberFormatter} from '@internationalized/number';
|
||||
import {useMemo} from 'react';
|
||||
import {useSelectedLocale} from './selected-locale';
|
||||
|
||||
export function useNumberFormatter(
|
||||
options: NumberFormatOptions = {}
|
||||
): Intl.NumberFormat {
|
||||
const {localeCode} = useSelectedLocale();
|
||||
return useMemo(
|
||||
() => new NumberFormatter(localeCode, options),
|
||||
[localeCode, options]
|
||||
);
|
||||
}
|
||||
51
common/resources/client/i18n/use-trans.ts
Executable file
51
common/resources/client/i18n/use-trans.ts
Executable file
@@ -0,0 +1,51 @@
|
||||
import {useCallback} from 'react';
|
||||
import memoize from 'nano-memoize';
|
||||
import {useSelectedLocale} from './selected-locale';
|
||||
import {handlePluralMessage} from './handle-plural-message';
|
||||
import {MessageDescriptor} from './message-descriptor';
|
||||
import {shallowEqual} from '../utils/shallow-equal';
|
||||
|
||||
export interface UseTransReturn {
|
||||
trans: (props: MessageDescriptor) => string;
|
||||
}
|
||||
|
||||
export function useTrans(): UseTransReturn {
|
||||
const {lines, localeCode} = useSelectedLocale();
|
||||
const trans = useCallback(
|
||||
(props: MessageDescriptor): string => {
|
||||
return translate({...props, lines, localeCode});
|
||||
},
|
||||
[lines, localeCode],
|
||||
);
|
||||
|
||||
return {trans};
|
||||
}
|
||||
|
||||
interface TranslateProps extends MessageDescriptor {
|
||||
lines?: Record<string, string>;
|
||||
localeCode: string;
|
||||
}
|
||||
const translate = memoize(
|
||||
(props: TranslateProps) => {
|
||||
let {lines, message, values, localeCode} = props;
|
||||
|
||||
if (message == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
message = lines?.[message] || lines?.[message.toLowerCase()] || message;
|
||||
|
||||
if (!values) {
|
||||
return message;
|
||||
}
|
||||
|
||||
message = handlePluralMessage(localeCode, props);
|
||||
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
message = message.replace(`:${key}`, `${value}`);
|
||||
});
|
||||
|
||||
return message;
|
||||
},
|
||||
{equals: shallowEqual, callTimeout: 0},
|
||||
);
|
||||
17
common/resources/client/i18n/use-user-timezone.ts
Executable file
17
common/resources/client/i18n/use-user-timezone.ts
Executable file
@@ -0,0 +1,17 @@
|
||||
import {useContext, useMemo} from 'react';
|
||||
import {BoostrapDataContext} from '../core/bootstrap-data/bootstrap-data-context';
|
||||
import {getLocalTimeZone} from '@internationalized/date';
|
||||
|
||||
export function useUserTimezone(): string {
|
||||
const {
|
||||
data: {user, settings},
|
||||
} = useContext(BoostrapDataContext);
|
||||
const defaultTimezone = settings.dates.default_timezone;
|
||||
const preferredTimezone = user?.timezone || defaultTimezone || 'auto';
|
||||
|
||||
return useMemo(() => {
|
||||
return !preferredTimezone || preferredTimezone === 'auto'
|
||||
? getLocalTimeZone()
|
||||
: preferredTimezone;
|
||||
}, [preferredTimezone]);
|
||||
}
|
||||
Reference in New Issue
Block a user