pl-fe: steal more dashboard code from mastodon
Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
@@ -4411,7 +4411,7 @@ class PlApiClient {
|
||||
getRetention: async (start_at: string, end_at: string, frequency: 'day' | 'month') => {
|
||||
const response = await this.request('/api/v1/admin/retention', { method: 'POST', params: { start_at, end_at, frequency } });
|
||||
|
||||
return v.parse(adminCohortSchema, response.json);
|
||||
return v.parse(filteredArray(adminCohortSchema), response.json);
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const adminCohortSchema = v.object({
|
||||
data: v.array(v.object({
|
||||
date: datetimeSchema,
|
||||
rate: v.number(),
|
||||
value: v.pipe(v.number(), v.integer()),
|
||||
value: v.pipe(v.unknown(), v.transform(Number)),
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ import * as v from 'valibot';
|
||||
*/
|
||||
const adminDimensionSchema = v.object({
|
||||
key: v.string(),
|
||||
data: v.object({
|
||||
data: v.array(v.object({
|
||||
key: v.string(),
|
||||
human_key: v.string(),
|
||||
value: v.string(),
|
||||
unit: v.fallback(v.optional(v.string()), undefined),
|
||||
human_value: v.fallback(v.optional(v.string()), undefined),
|
||||
}),
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pl-api",
|
||||
"version": "1.0.0-rc.57",
|
||||
"version": "1.0.0-rc.58",
|
||||
"type": "module",
|
||||
"homepage": "https://github.com/mkljczk/pl-fe/tree/develop/packages/pl-api",
|
||||
"repository": {
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"multiselect-react-dropdown": "^2.0.25",
|
||||
"mutative": "^1.1.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"pl-api": "^1.0.0-rc.57",
|
||||
"pl-api": "^1.0.0-rc.58",
|
||||
"postcss": "^8.5.3",
|
||||
"process": "^0.11.10",
|
||||
"punycode": "^2.1.1",
|
||||
|
||||
@@ -79,7 +79,7 @@ const Counter: React.FC<ICounter> = ({
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
<div>
|
||||
<div className='mt-auto'>
|
||||
<Sparklines width={259} height={55} data={data?.[0].data.map(x => x.value * 1) || []}>
|
||||
<SparklinesCurve />
|
||||
</Sparklines>
|
||||
@@ -87,15 +87,17 @@ const Counter: React.FC<ICounter> = ({
|
||||
</>
|
||||
);
|
||||
|
||||
const className = 'relative flex flex-col rounded bg-gray-200 font-medium dark:bg-gray-800';
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link to={to} className='relative rounded bg-gray-200 font-medium transition-transform hover:-translate-y-1 dark:bg-gray-800' target={target}>
|
||||
<Link to={to} className={clsx(className, 'transition-transform hover:-translate-y-1')} target={target}>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className='relative rounded bg-gray-200 font-medium dark:bg-gray-800'>
|
||||
<div className={className}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ interface IDashCounters {
|
||||
|
||||
/** Wrapper container for dash counters. */
|
||||
const DashCounters: React.FC<IDashCounters> = ({ children }) => (
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-2'>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
83
packages/pl-fe/src/features/admin/components/dimension.tsx
Normal file
83
packages/pl-fe/src/features/admin/components/dimension.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { FormattedNumber } from 'react-intl';
|
||||
|
||||
import Text from 'pl-fe/components/ui/text';
|
||||
import { useDimensions } from 'pl-fe/queries/admin/use-metrics';
|
||||
|
||||
import type { AdminDimensionKey, AdminGetDimensionsParams } from 'pl-api';
|
||||
|
||||
interface IDimension {
|
||||
dimension: AdminDimensionKey;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
label: JSX.Element;
|
||||
params: AdminGetDimensionsParams;
|
||||
}
|
||||
|
||||
const Dimension: React.FC<IDimension> = ({
|
||||
dimension,
|
||||
startAt,
|
||||
endAt,
|
||||
label,
|
||||
params,
|
||||
}) => {
|
||||
const { data } = useDimensions([dimension], { ...params, start_at: startAt, end_at: endAt });
|
||||
|
||||
let content;
|
||||
|
||||
if (!data) {
|
||||
content = (
|
||||
<table>
|
||||
<tbody>
|
||||
{Array.from(Array(params.limit)).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
{/* <Skeleton width={100} /> */}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{/* <Skeleton width={60} /> */}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
} else {
|
||||
const sum = data[0].data.reduce((sum, cur) => sum + (+cur.value * 1), 0);
|
||||
|
||||
content = (
|
||||
<table className='w-full'>
|
||||
<tbody>
|
||||
{data[0].data.map(item => (
|
||||
<tr className='border-b border-primary-200 last:border-none dark:border-gray-800' key={item.key}>
|
||||
<td className='p-2.5'>
|
||||
<span
|
||||
className='mr-2 inline-block size-2 rounded-full bg-green-500 shadow-sm'
|
||||
style={{ opacity: +item.value / sum }}
|
||||
/>
|
||||
<Text title={item.key} weight='medium' size='xs' tag='span'>{item.human_key}</Text>
|
||||
</td>
|
||||
|
||||
<td className='p-2.5 text-end'>
|
||||
<Text size='xs'>
|
||||
{typeof item.human_value !== 'undefined' ? item.human_value : <FormattedNumber value={+item.value} />}
|
||||
</Text>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text className='border-b border-primary-200 pb-1 dark:border-gray-800' weight='medium' size='sm'>{label}</Text>
|
||||
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Dimension };
|
||||
132
packages/pl-fe/src/features/admin/components/retention.tsx
Normal file
132
packages/pl-fe/src/features/admin/components/retention.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { FormattedDate, FormattedMessage, FormattedNumber } from 'react-intl';
|
||||
|
||||
import Text from 'pl-fe/components/ui/text';
|
||||
import { useRetention } from 'pl-fe/queries/admin/use-metrics';
|
||||
|
||||
import type { AdminCohort } from 'pl-api';
|
||||
|
||||
const dateForCohort = (cohort: AdminCohort) => {
|
||||
const timeZone = 'UTC';
|
||||
switch (cohort.frequency) {
|
||||
case 'day':
|
||||
return <FormattedDate value={cohort.period} month='long' day='2-digit' timeZone={timeZone} />;
|
||||
default:
|
||||
return <FormattedDate value={cohort.period} month='long' year='numeric' timeZone={timeZone} />;
|
||||
}
|
||||
};
|
||||
|
||||
interface IRetention {
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
frequency: 'day' | 'month';
|
||||
}
|
||||
|
||||
const Retention: React.FC<IRetention> = ({ startAt, endAt, frequency }) => {
|
||||
const { data } = useRetention(startAt, endAt, frequency);
|
||||
|
||||
let content;
|
||||
|
||||
if (!data) {
|
||||
content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading…' />;
|
||||
} else {
|
||||
content = (
|
||||
<table className='text-xs'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<div className='p-2.5 pl-0 font-bold'>
|
||||
<FormattedMessage id='admin.dashboard.retention.cohort' defaultMessage='Sign-up month' />
|
||||
</div>
|
||||
</th>
|
||||
|
||||
<th>
|
||||
<div className='p-2.5 font-bold'>
|
||||
<FormattedMessage id='admin.dashboard.retention.cohort_size' defaultMessage='New users' />
|
||||
</div>
|
||||
</th>
|
||||
|
||||
{data[0].data.slice(1).map((retention, i) => (
|
||||
<th key={retention.date} className='w-14'>
|
||||
<div className='p-2.5 font-bold'>
|
||||
{i + 1}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<div className='p-2.5 pl-0 font-bold'>
|
||||
<FormattedMessage id='admin.dashboard.retention.average' defaultMessage='Average' />
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div className='p-2.5 text-center'>
|
||||
<FormattedNumber value={data.reduce((sum, cohort, i) => sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} />
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{data[0].data.slice(1).map((retention, i) => {
|
||||
const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].rate - sum) / (k + 1) : sum, 0);
|
||||
|
||||
return (
|
||||
<td key={retention.date}>
|
||||
<div className='bg-primary-200 p-2.5 font-medium dark:bg-gray-800' style={{ ['--tw-bg-opacity' as any]: 0.5 + average / 2 }}>
|
||||
<FormattedNumber value={average} style='percent' />
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{data.slice(0, -1).map(cohort => (
|
||||
<tr key={cohort.period}>
|
||||
<td>
|
||||
<div className='p-2.5 pl-0'>
|
||||
{dateForCohort(cohort)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div className='p-2.5 text-center'>
|
||||
<FormattedNumber value={cohort.data[0].value} />
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{cohort.data.slice(1).map(retention => (
|
||||
<td key={retention.date}>
|
||||
<div className='bg-primary-200 p-2.5 font-medium dark:bg-gray-800' style={{ ['--tw-bg-opacity' as any]: 0.5 + retention.rate / 2 }}>
|
||||
<FormattedNumber value={retention.rate} style='percent' />
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
let title = null;
|
||||
switch (frequency) {
|
||||
case 'day':
|
||||
title = <FormattedMessage id='admin.dashboard.daily_retention' defaultMessage='User retention rate by day after sign-up' />;
|
||||
break;
|
||||
default:
|
||||
title = <FormattedMessage id='admin.dashboard.monthly_retention' defaultMessage='User retention rate by month after sign-up' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='col-span-2'>
|
||||
<Text className='border-b border-primary-200 pb-1 dark:border-gray-800' weight='medium' size='sm'>{title}</Text>
|
||||
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Retention };
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, FormattedNumber } from 'react-intl';
|
||||
|
||||
import List, { ListItem } from 'pl-fe/components/list';
|
||||
import { CardTitle } from 'pl-fe/components/ui/card';
|
||||
import Icon from 'pl-fe/components/ui/icon';
|
||||
import Stack from 'pl-fe/components/ui/stack';
|
||||
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
|
||||
import { useFeatures } from 'pl-fe/hooks/use-features';
|
||||
import { useInstance } from 'pl-fe/hooks/use-instance';
|
||||
import { useOwnAccount } from 'pl-fe/hooks/use-own-account';
|
||||
@@ -12,13 +13,20 @@ import sourceCode from 'pl-fe/utils/code';
|
||||
|
||||
import { Counter } from '../components/counter';
|
||||
import { DashCounter, DashCounters } from '../components/dashcounter';
|
||||
import { Dimension } from '../components/dimension';
|
||||
import RegistrationModePicker from '../components/registration-mode-picker';
|
||||
import { Retention } from '../components/retention';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const instance = useInstance();
|
||||
const features = useFeatures();
|
||||
const { account } = useOwnAccount();
|
||||
|
||||
const { pendingReports, pendingUsers } = useAppSelector((state) => ({
|
||||
pendingReports: state.admin.openReports.length,
|
||||
pendingUsers: state.admin.awaitingApproval.length,
|
||||
}));
|
||||
|
||||
const v = features.version;
|
||||
|
||||
const {
|
||||
@@ -30,8 +38,9 @@ const Dashboard: React.FC = () => {
|
||||
const mau = instance.usage.users.active_month ?? instance.pleroma.stats.mau;
|
||||
const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : undefined;
|
||||
|
||||
const [endDay] = useState<string>(new Date().toISOString().slice(0, 10));
|
||||
const [startDay] = useState<string>(new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10));
|
||||
const [today] = useState<string>(new Date().toISOString().slice(0, 10));
|
||||
const [monthAgo] = useState<string>(new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10));
|
||||
const [sixMonthsAgo] = useState<string>(new Date(new Date().getTime() - 30 * 6 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
@@ -41,8 +50,8 @@ const Dashboard: React.FC = () => {
|
||||
{features.mastodonAdminMetrics ? (
|
||||
<Counter
|
||||
measure='new_users'
|
||||
startAt={startDay}
|
||||
endAt={endDay}
|
||||
startAt={monthAgo}
|
||||
endAt={today}
|
||||
to='/pl-fe/admin/users'
|
||||
label={<FormattedMessage id='admin.counters.new_users' defaultMessage='new users' />}
|
||||
/>
|
||||
@@ -56,8 +65,8 @@ const Dashboard: React.FC = () => {
|
||||
{features.mastodonAdminMetrics ? (
|
||||
<Counter
|
||||
measure='active_users'
|
||||
startAt={startDay}
|
||||
endAt={endDay}
|
||||
startAt={monthAgo}
|
||||
endAt={today}
|
||||
label={<FormattedMessage id='admin.counters.active_users' defaultMessage='active users' />}
|
||||
/>
|
||||
) : (
|
||||
@@ -77,21 +86,21 @@ const Dashboard: React.FC = () => {
|
||||
<>
|
||||
<Counter
|
||||
measure='interactions'
|
||||
startAt={startDay}
|
||||
endAt={endDay}
|
||||
startAt={monthAgo}
|
||||
endAt={today}
|
||||
label={<FormattedMessage id='admin.counters.interactions' defaultMessage='interactions' />}
|
||||
/>
|
||||
<Counter
|
||||
measure='opened_reports'
|
||||
startAt={startDay}
|
||||
endAt={endDay}
|
||||
startAt={monthAgo}
|
||||
endAt={today}
|
||||
to='/pl-fe/admin/reports'
|
||||
label={<FormattedMessage id='admin.counters.opened_reports' defaultMessage='reports opened' />}
|
||||
/>
|
||||
<Counter
|
||||
measure='resolved_reports'
|
||||
startAt={startDay}
|
||||
endAt={endDay}
|
||||
startAt={monthAgo}
|
||||
endAt={today}
|
||||
to='/pl-fe/admin/reports'
|
||||
label={<FormattedMessage id='admin.counters.resolved_reports' defaultMessage='reports resolved' />}
|
||||
/>
|
||||
@@ -106,6 +115,52 @@ const Dashboard: React.FC = () => {
|
||||
count={domainCount}
|
||||
label={<FormattedMessage id='admin.dashcounters.domain_count_label' defaultMessage='peers' />}
|
||||
/>
|
||||
<List>
|
||||
<ListItem size='sm' to='/pl-fe/admin/reports' label={<FormattedMessage id='admin.links.pending_reports' defaultMessage='{count, plural, one {{formattedCount} pending report} other {{formattedCount} pending reports}}' values={{ count: pendingReports, formattedCount: <strong><FormattedNumber value={pendingReports} /></strong> }} />} />
|
||||
<ListItem size='sm' to='/pl-fe/admin/users' label={<FormattedMessage id='admin.links.pending_users' defaultMessage='{count, plural, one {{formattedCount} pending user} other {{formattedCount} pending users}}' values={{ count: pendingUsers, formattedCount: <strong><FormattedNumber value={pendingUsers} /></strong> }} />} />
|
||||
{/* <ListItem size='sm' to='/pl-fe/admin' label={<FormattedMessage id='admin.links.pending_tags' defaultMessage='{count} pending tags' values={{ count: <strong>0</strong> }} />} />
|
||||
<ListItem size='sm' to='/pl-fe/admin' label={<FormattedMessage id='admin.links.pending_appeals' defaultMessage='{count} pending appeals' values={{ count: <strong>0</strong> }} />} /> */}
|
||||
</List>
|
||||
{features.mastodonAdminMetrics && (
|
||||
<>
|
||||
<Dimension
|
||||
dimension='sources'
|
||||
startAt={monthAgo}
|
||||
endAt={today}
|
||||
params={{ limit: 8 }}
|
||||
label={<FormattedMessage id='admin.dimensions.sources' defaultMessage='Sign-up sources' />}
|
||||
/>
|
||||
<Dimension
|
||||
dimension='languages'
|
||||
startAt={monthAgo}
|
||||
endAt={today}
|
||||
params={{ limit: 8 }}
|
||||
label={<FormattedMessage id='admin.dimensions.top_languages' defaultMessage='Top active languages' />}
|
||||
/>
|
||||
<Dimension
|
||||
dimension='servers'
|
||||
startAt={monthAgo}
|
||||
endAt={today}
|
||||
params={{ limit: 8 }}
|
||||
label={<FormattedMessage id='admin.dimensions.top_servers' defaultMessage='Top active servers' />}
|
||||
/>
|
||||
<Retention startAt={sixMonthsAgo} endAt={today} frequency='month' />
|
||||
<Dimension
|
||||
dimension='software_versions'
|
||||
startAt={monthAgo}
|
||||
endAt={today}
|
||||
params={{ limit: 4 }}
|
||||
label={<FormattedMessage id='admin.dimensions.software' defaultMessage='Software' />}
|
||||
/>
|
||||
<Dimension
|
||||
dimension='space_usage'
|
||||
startAt={monthAgo}
|
||||
endAt={today}
|
||||
params={{ limit: 3 }}
|
||||
label={<FormattedMessage id='admin.dimensions.media_storage' defaultMessage='Media storage' />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DashCounters>
|
||||
|
||||
<List>
|
||||
@@ -175,9 +230,11 @@ const Dashboard: React.FC = () => {
|
||||
</a>
|
||||
</ListItem>
|
||||
|
||||
<ListItem label={<FormattedMessage id='admin.software.backend' defaultMessage='Backend' />}>
|
||||
<span>{v.software + (v.build ? `+${v.build}` : '')} {v.version}</span>
|
||||
</ListItem>
|
||||
{!features.mastodonAdminMetrics && (
|
||||
<ListItem label={<FormattedMessage id='admin.software.backend' defaultMessage='Backend' />}>
|
||||
<span>{v.software + (v.build ? `+${v.build}` : '')} {v.version}</span>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -107,6 +107,8 @@
|
||||
"admin.counters.new_users": "new users",
|
||||
"admin.counters.opened_reports": "reports opened",
|
||||
"admin.counters.resolved_reports": "reports resolved",
|
||||
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
|
||||
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up",
|
||||
"admin.dashboard.registration_mode.approval_hint": "Users can sign up, but their account only gets activated when an admin approves it.",
|
||||
"admin.dashboard.registration_mode.approval_label": "Approval Required",
|
||||
"admin.dashboard.registration_mode.closed_hint": "Nobody can sign up. You can still invite people.",
|
||||
@@ -114,6 +116,9 @@
|
||||
"admin.dashboard.registration_mode.open_hint": "Anyone can join.",
|
||||
"admin.dashboard.registration_mode.open_label": "Open",
|
||||
"admin.dashboard.registration_mode_label": "Registrations",
|
||||
"admin.dashboard.retention.average": "Average",
|
||||
"admin.dashboard.retention.cohort": "Sign-up month",
|
||||
"admin.dashboard.retention.cohort_size": "New users",
|
||||
"admin.dashboard.settings_saved": "Settings saved!",
|
||||
"admin.dashcounters.domain_count_label": "peers",
|
||||
"admin.dashcounters.mau_label": "monthly active users",
|
||||
@@ -121,6 +126,11 @@
|
||||
"admin.dashcounters.status_count_label": "posts",
|
||||
"admin.dashcounters.user_count_label": "total users",
|
||||
"admin.dashwidgets.software_header": "Software",
|
||||
"admin.dimensions.media_storage": "Media storage",
|
||||
"admin.dimensions.software": "Software",
|
||||
"admin.dimensions.sources": "Sign-up sources",
|
||||
"admin.dimensions.top_languages": "Top active languages",
|
||||
"admin.dimensions.top_servers": "Top active servers",
|
||||
"admin.domains.action": "Create domain",
|
||||
"admin.domains.delete": "Delete",
|
||||
"admin.domains.edit": "Edit",
|
||||
@@ -161,6 +171,8 @@
|
||||
"admin.edit_rule.updated": "Rule edited",
|
||||
"admin.latest_accounts_panel.more": "Click to see {count, plural, one {# account} other {# accounts}}",
|
||||
"admin.latest_accounts_panel.title": "Latest Accounts",
|
||||
"admin.links.pending_reports": "{count, plural, one {{formattedCount} pending report} other {{formattedCount} pending reports}}",
|
||||
"admin.links.pending_users": "{count, plural, one {{formattedCount} pending user} other {{formattedCount} pending users}}",
|
||||
"admin.moderation_log.empty_message": "You have not performed any moderation actions yet. When you do, a history will be shown here.",
|
||||
"admin.relays.add.fail": "Failed to follow the instance relay",
|
||||
"admin.relays.add.success": "Instance relay followed",
|
||||
|
||||
@@ -49,9 +49,12 @@ const shortNumberFormat = (number: any, max?: number): React.ReactNode => {
|
||||
/** Check if an entity ID is an integer (eg not a FlakeId). */
|
||||
const isIntegerId = (id: string): boolean => new RegExp(/^-?[0-9]+$/g).test(id);
|
||||
|
||||
const roundTo10 = (num: number) => Math.round(num / 10) * 10;
|
||||
|
||||
export {
|
||||
isNumber,
|
||||
roundDown,
|
||||
shortNumberFormat,
|
||||
isIntegerId,
|
||||
roundTo10,
|
||||
};
|
||||
|
||||
@@ -6858,10 +6858,10 @@ pkg-dir@^4.1.0:
|
||||
dependencies:
|
||||
find-up "^4.0.0"
|
||||
|
||||
pl-api@^1.0.0-rc.57:
|
||||
version "1.0.0-rc.57"
|
||||
resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-1.0.0-rc.57.tgz#848259e4a38e3c44dc3048a86578d877b36e50e7"
|
||||
integrity sha512-ndT9fnL0LJt5EMRMHWRovAd/YE8w6Ya/Ow1TUbiwM760IqQUAIyxBNt5gLB89OlkbJQiVC+RD/vVUxBdBMisLg==
|
||||
pl-api@^1.0.0-rc.58:
|
||||
version "1.0.0-rc.58"
|
||||
resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-1.0.0-rc.58.tgz#3605128a53029ef4abd8316fdabe3ab4e0c2552a"
|
||||
integrity sha512-GR/gXEQGwo1SueSwPzPsRUeuTpulRx5Wpd3y6/bOC7r8BGJyKK0FI34/y3PYLEzBveYo5W2JP+tCI4dUKk+akQ==
|
||||
dependencies:
|
||||
blurhash "^2.0.5"
|
||||
http-link-header "^1.1.3"
|
||||
|
||||
Reference in New Issue
Block a user