diff --git a/.github/workflows/app-staging.yml b/.github/workflows/app-staging.yml index d073c968..99c54ce6 100644 --- a/.github/workflows/app-staging.yml +++ b/.github/workflows/app-staging.yml @@ -104,7 +104,7 @@ jobs: run: >- T="$(date +%s)"; - yarn build --prefix-paths; + yarn build --prefix-paths --profile; exit_code=$?; T="$(($(date +%s)-T))"; diff --git a/.github/workflows/docs-staging.yml b/.github/workflows/docs-staging.yml index ece18b50..778e7379 100644 --- a/.github/workflows/docs-staging.yml +++ b/.github/workflows/docs-staging.yml @@ -105,7 +105,7 @@ jobs: run: >- T="$(date +%s)"; - yarn build --prefix-paths; + yarn build --prefix-paths --profile; exit_code=$?; T="$(($(date +%s)-T))"; diff --git a/app/package.json b/app/package.json index cbf2179c..5e1fd018 100644 --- a/app/package.json +++ b/app/package.json @@ -48,6 +48,7 @@ "node-fetch": "^2.6.0", "polished": "^3.6.5", "react": "^16.13.1", + "react-countup": "^4.3.3", "react-data-grid": "^7.0.0-canary.17", "react-dom": "^16.12.0", "react-helmet": "^6.1.0", @@ -57,16 +58,20 @@ "react-switch": "^5.0.1", "react-transition-group": "^4.4.1", "react-virtualized-auto-sizer": "^1.0.2", + "react-wordcloud": "^1.2.7", + "recharts": "^1.8.5", "redux": "^4.0.5", "redux-batched-actions": "^0.5.0", "redux-saga": "^1.1.3", + "reselect": "^4.0.0", "s-ago": "^2.2.0", "shallow-equal": "^1.2.1", "socket.io-client": "^2.3.0", "ts-node": "^8.10.2", "tsconfig-paths": "^3.9.0", "twemoji": "^13.0.1", - "typescript": "^3.9.5" + "typescript": "^3.9.5", + "yarn": "^1.22.10" }, "repository": { "type": "git", @@ -85,6 +90,7 @@ "@types/react-redux": "^7.1.9", "@types/react-select": "^3.0.14", "@types/react-virtualized-auto-sizer": "^1.0.0", + "@types/recharts": "^1.8.18", "@types/socket.io-client": "^1.4.33", "@types/twemoji": "^12.1.1", "@types/underscore.string": "^0.0.38", diff --git a/app/src/api.ts b/app/src/api.ts index edb21465..73afae57 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -15,7 +15,7 @@ export const GATEWAY_API_BASE: string = process.env.GATSBY_PRODUCTION : "https://gateway.develop.archit.us"; /** - * resolves a path with he optional base path + * resolves a path with the optional base path * @param path - base path with leading / */ export function withBasePath(path: string): string { diff --git a/app/src/components/CustomEmoji.tsx b/app/src/components/CustomEmoji.tsx new file mode 100644 index 00000000..7b54c09e --- /dev/null +++ b/app/src/components/CustomEmoji.tsx @@ -0,0 +1,112 @@ +import { styled } from "linaria/react"; +import { transparentize } from "polished"; +import React from "react"; + +import { OtherColors } from "@app/theme/color"; +import { snowflakeToDate } from "@app/utility/discord"; +import { CustomEmoji, Member, Snowflake } from "@app/utility/types"; +import Tooltip from "@architus/facade/components/Tooltip"; +import { + isDefined, + formatDateExtraShort, + formatNum, +} from "@architus/lib/utility"; +import { gap } from "@architus/facade/theme/spacing"; + +const Styled = { + TooltipName: styled.strong` + display: block; + `, + TooltipElevated: styled.div` + opacity: 0.7; + font-size: 80%; + `, + OuterContainer: styled.div` + display: flex; + align-items: center; + justify-content: center; + img { + object-fit: cover; + width: 100%; + height: 90px; + margin-right: ${gap.nano}; + max-width: 300px; + } + `, + InnerContainer: styled.div` + display: flex; + flex-direction: column; + flex-shrink: 0; + `, + ImageContainer: styled.div` + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + `, + PreviewContainer: styled.div` + + `, +}; + +type CustomEmojiProps = { + emoji: CustomEmoji; + author?: Member; + className?: string; + style?: React.CSSProperties; +}; + +/** + * Wraps a CustomEmoji object in a formatted tooltip showing name, author name, creation date, and usage. + */ +export const CustomEmojiIcon: React.FC = ({ + emoji, + author, + style, + className, +}) => { + let authorName = null; + if (isDefined(author) && author.id === emoji.authorId.getOrElse("0" as Snowflake)) { + authorName = ( + + {author.name}#{author.discriminator} + + ); + } + const architusDate = snowflakeToDate((emoji.id as string) as Snowflake); + const discordDate = emoji.discordId.isDefined() + ? snowflakeToDate(emoji.discordId.get) + : new Date(8640000000000000); + const date = formatDateExtraShort( + architusDate < discordDate ? architusDate : discordDate + ); + + return ( + + + + :{emoji.name}: + {authorName} + {date} + + uses: {formatNum(emoji.numUses)} + + + + } + > + + + + + ); +}; diff --git a/app/src/components/Mention.tsx b/app/src/components/Mention.tsx new file mode 100644 index 00000000..d23fb947 --- /dev/null +++ b/app/src/components/Mention.tsx @@ -0,0 +1,113 @@ +import { styled } from "linaria/react"; +import { transparentize } from "polished"; +import React from "react"; +import ago from "s-ago"; + +import { getAvatarUrl } from "@app/components/UserDisplay"; +import { OtherColors } from "@app/theme/color"; +import { Member } from "@app/utility/types"; +import Tooltip from "@architus/facade/components/Tooltip"; +import { gap } from "@architus/facade/theme/spacing"; + +const Styled = { + Tooltip: styled(Tooltip)``, + TooltipName: styled.strong` + display: block; + `, + TooltipElevated: styled.div` + opacity: 0.7; + font-size: 80%; + `, + Avatar: styled.img` + border-radius: 50%; + max-width: 70px; + height: auto; + margin: ${gap.pico} ${gap.nano} ${gap.pico} 0; + `, + OuterContainer: styled.div` + display: flex; + `, + InnerContainer: styled.div` + display: flex; + flex-direction: column; + justify-content: center; + white-space: nowrap; + text-align: left; + `, + NameContainer: styled.div<{ color: string }>` + border: 1px solid ${(props): string => props.color}; + border-radius: 10px; + width: fit-content; + padding: 0 ${gap.femto} 0 ${gap.nano}; + margin-bottom: ${gap.femto}; + `, + ColoredCircle: styled.div<{ color: string }>` + height: 10px; + width: 10px; + background-color: ${(props): string => props.color}; + border-radius: 50%; + display: inline-block; + margin: 0 ${gap.femto} 0 -${gap.pico}; + `, + Mention: styled.div` + max-width: 100%; + color: ${OtherColors.Discord}; + background-color: ${transparentize(0.85, OtherColors.Discord)}; + + & > p { + max-width: 100%; + } + + &:hover { + color: white; + background-color: ${transparentize(0.25, OtherColors.Discord)}; + } + `, +}; + +type MentionProps = { + member: Member; + className?: string; + style?: React.CSSProperties; +}; + +/** + * Render a Member object in the style of a discord mention in light or dark mode. + * Also wraps in a tooltip showing avatar, username, and join date. + */ +export const Mention: React.FC = ({ + member, + style, + className, +}) => { + let color = member.color.isDefined() ? member.color.get : "white"; + if (color === "#000000") { + // because discord uses black instead of 'undefined' + color = "white"; + } + return ( + + + + + + {member.name}#{member.discriminator} + + Joined {ago(new Date(member.joined_at))} + + + } + > + +

+ @{member.nick.isDefined() ? member.nick.get : member.name} +

+
+
+ ); +}; diff --git a/app/src/components/Timeline.tsx b/app/src/components/Timeline.tsx new file mode 100644 index 00000000..1a743f9f --- /dev/null +++ b/app/src/components/Timeline.tsx @@ -0,0 +1,170 @@ +import { styled } from "linaria/react"; +import React, { useMemo } from "react"; +import { FaDotCircle } from "react-icons/fa"; +import { IconType } from "react-icons/lib"; + +import Tooltip from "@architus/facade/components/Tooltip"; +import { mode, ColorMode } from "@architus/facade/theme/color"; +import { down } from "@architus/facade/theme/media"; +import { formatDate, formatDateExtraShort } from "@architus/lib/utility"; + +const Styled = { + Timeline: styled.div` + max-width: 100%; + display: flex; + height: 100%; + padding: 20px 0; + `, + Line: styled.div` + width: 2px; + margin: 15px 10px 36px 10px; + `, + List: styled.div` + flex: 1 1 auto; + display: flex; + justify-content: space-between; + height: 100%; + flex-direction: column; + `, + Container: styled.div` + padding: 10px 10px; + ${down("md")} { + padding: 5px 10px; + } + display: flex; + + &:first-child::after { + position: absolute; + left: 43px; + content: ""; + display: block; + height: 170px; + width: 2px; + ${mode(ColorMode.Light)} { + background-color: #676767; + } + ${mode(ColorMode.Dark)} { + background-color: #767e87; + } + } + &:last-child::after { + position: absolute; + left: 43px; + transform: translateY(-160px); + content: ""; + display: block; + height: 170px; + width: 2px; + ${mode(ColorMode.Light)} { + background-color: #676767; + } + ${mode(ColorMode.Dark)} { + background-color: #767e87; + } + } + `, + Dot: styled.div` + content: ''; + flex 0 0 auto; + position: absolute; + width: 18px; + height: 18px; + left: 36px; + ${mode(ColorMode.Light)} { + background-color: #676767; + border: 3px solid #ffffff; + } + ${mode(ColorMode.Dark)} { + background-color: #767e87; + border: 3px solid #31363f; + } + border-radius: 50%; + z-index: 1; + `, + IconContainer: styled.div` + padding: 2px; + & > * { + z-index: 1; + position: absolute; + left: 36px; + ${mode(ColorMode.Light)} { + background-color: #ffffff; + outline: 4px solid #ffffff; + } + ${mode(ColorMode.Dark)} { + background-color: #31363f; + outline: 4px solid #31363f; + } + } + `, + Content: styled.div` + max-width: fit-content; + font-size: 0.875rem; + `, +}; + +type TimelineProps = { + className?: string; + style?: React.CSSProperties; + innerProps?: Partial>; +}; + +type TimelineItemProps = { + date: Date; + icon?: IconType; + dateFormatter?: (date: Date) => string; + className?: string; + style?: React.CSSProperties; + innerProps?: Partial>; +}; + +/** + * Display a list of TimelineItem ordered by their date. + */ +export const Timeline: React.FC = ({ + style, + className, + children, + innerProps = {}, +}) => { + const childrenArray = useMemo( + () => + (React.Children.toArray(children) as React.ReactElement[]).sort( + (a, b) => a.props.date - b.props.date + ), + [children] + ); + return ( + + + {childrenArray} + + ); +}; + +/** + * The child of Timeline; takes a date to display as the header and determine order. + * Also takes an icon to show on the line and may display a short description. + */ +export const TimelineItem: React.FC = ({ + date, + style, + icon = FaDotCircle, + dateFormatter = formatDateExtraShort, + className, + children, + innerProps = {}, +}) => { + const iconElem = React.createElement(icon, {}); + return ( + + {iconElem} + + {formatDate(date)}

}> +

{dateFormatter(date)}

+ {children} +
+
+
+ ); +}; diff --git a/app/src/data/path-prefix.ts b/app/src/data/path-prefix.ts index f5826422..671f53c3 100644 --- a/app/src/data/path-prefix.ts +++ b/app/src/data/path-prefix.ts @@ -14,5 +14,5 @@ export function usePathPrefix(): Option { } `); - return Option.from(queryResult.site?.pathPrefix); + return Option.fromString(queryResult.site?.pathPrefix); } diff --git a/app/src/data/umami-analytics.ts b/app/src/data/umami-analytics.ts index ef506c62..b44fdbfe 100644 --- a/app/src/data/umami-analytics.ts +++ b/app/src/data/umami-analytics.ts @@ -12,6 +12,9 @@ export type UmamiAnalytics = { * Gets the site's Umami analytics parameters if they exist */ export function useUmamiAnalytics(): Option { + // this seems to be required due to a bug with useStaticQuery in gatsby v2.23.11 + if (process.env.NODE_ENV !== "production") return None; + // eslint-disable-next-line react-hooks/rules-of-hooks const queryResult = useStaticQuery< GatsbyTypes.UseUmamiAnalyticsQuery >(graphql` @@ -27,18 +30,18 @@ export function useUmamiAnalytics(): Option { } `); - const umami = queryResult.site?.siteMetadata?.umami; - if ( - isDefined(umami) && - isDefined(umami.websiteId) && - isDefined(umami.base) && - (umami.websiteId.length ?? 0) > 0 - ) { - return Some({ - websiteId: umami.websiteId, - base: umami.base, - }); - } + // const umami = queryResult.site?.siteMetadata?.umami; + // if ( + // isDefined(umami) && + // isDefined(umami.websiteId) && + // isDefined(umami.base) && + // (umami.websiteId.length ?? 0) > 0 + // ) { + // return Some({ + // websiteId: umami.websiteId, + // base: umami.base, + // }); + // } return None; } diff --git a/app/src/store/actions.ts b/app/src/store/actions.ts index 7d47c17d..fd0ffd26 100644 --- a/app/src/store/actions.ts +++ b/app/src/store/actions.ts @@ -3,3 +3,4 @@ export * from "@app/store/slices/notifications"; export * from "@app/store/slices/loading"; export * from "@app/store/slices/guildCount"; export * from "@app/store/slices/interpret"; +export * from "@app/store/slices/pools"; \ No newline at end of file diff --git a/app/src/store/api/rest/middleware.ts b/app/src/store/api/rest/middleware.ts index d2aeaa44..7db5f9d4 100644 --- a/app/src/store/api/rest/middleware.ts +++ b/app/src/store/api/rest/middleware.ts @@ -37,7 +37,10 @@ const RestMiddleware: Middleware<{}, Store, ReduxDispatch> = ({ .then((result) => { const end = performance.now(); const duration = end - start; + console.log("hello1"); const { data: response } = result; + console.log("hello2"); + console.log(route); dispatch( restSuccess({ ...action.payload, @@ -45,8 +48,10 @@ const RestMiddleware: Middleware<{}, Store, ReduxDispatch> = ({ timing: { start, end, duration }, }) ); + console.log("hello3"); }) .catch((e) => { + console.log(e); const end = performance.now(); const duration = start - end; let error: ApiError; diff --git a/app/src/store/api/rest/types.ts b/app/src/store/api/rest/types.ts index 205da575..b01d79d7 100644 --- a/app/src/store/api/rest/types.ts +++ b/app/src/store/api/rest/types.ts @@ -139,7 +139,7 @@ export interface RestSuccess< } /** - * Rest success action payload + * Rest failure action payload */ export interface RestFailure< TData extends Record = Record, diff --git a/app/src/store/routes/rest.ts b/app/src/store/routes/rest.ts index 86f0cccd..667551d7 100644 --- a/app/src/store/routes/rest.ts +++ b/app/src/store/routes/rest.ts @@ -4,7 +4,15 @@ import * as t from "io-ts"; import { makeRoute } from "@app/store/api/rest"; import { Errors } from "@app/store/api/rest/types"; import { HttpVerbs } from "@app/utility"; -import { User, Access, Guild } from "@app/utility/types"; +import { + User, + Access, + Guild, + EpochFromString, + Snowflake, + HoarFrost, + THoarFrost, +} from "@app/utility/types"; export type IdentifySessionResponse = t.TypeOf; export const IdentifySessionResponse = t.interface({ @@ -103,3 +111,65 @@ export const guilds = makeRoute()({ decode: (response: unknown): Either => either.chain(t.object.decode(response), GuildsListResponse.decode), }); + +/** + * POST /emojis/[guild_id]/[hoar_frost] + */ +export const loadCustomEmoji = makeRoute()({ + label: "custEmoji/load", + route: ({ guildId, emojiId }: { guildId: Snowflake; emojiId: HoarFrost }) => + `/emojis/${guildId}/${emojiId}`, + method: HttpVerbs.POST, + auth: true, +}); + +/** + * PATCH /emojis/[guild_id]/[hoar_frost] + */ +export const cacheCustomEmoji = makeRoute()({ + label: "custEmoji/cache", + route: ({ guildId, emojiId }: { guildId: Snowflake; emojiId: HoarFrost }) => + `/emojis/${guildId}/${emojiId}`, + method: HttpVerbs.PATCH, + auth: true, +}); + +/** + * DELETE /emojis/[guild_id]/[hoar_frost] + */ +export const deleteCustomEmoji = makeRoute()({ + label: "custEmoji/delete", + route: ({ guildId, emojiId }: { guildId: Snowflake; emojiId: HoarFrost }) => + `/emojis/${guildId}/${emojiId}`, + method: HttpVerbs.DELETE, + auth: true, +}); + +export type StatisticsResponse = t.TypeOf; + +export const StatisticsResponse = t.interface({ + memberCount: t.number, + messageCount: t.number, + architusCount: t.number, + commonWords: t.array(t.tuple([t.string, t.number])), + mentionCounts: t.record(t.string, t.number), + memberCounts: t.record(t.string, t.number), + channelCounts: t.record(t.string, t.number), + timeMemberCounts: t.record(EpochFromString, t.record(t.string, t.number)), + upToDate: t.boolean, + forbidden: t.boolean, + lastActivity: EpochFromString, + popularEmojis: t.array(THoarFrost), +}); + +/** + * GET /stats/ + */ +export const fetchStats = makeRoute()({ + label: "stats", + route: ({ guildId }: { guildId: Snowflake }) => `/stats/${guildId}`, + method: HttpVerbs.GET, + auth: true, + decode: (response: unknown): Either => + either.chain(t.object.decode(response), StatisticsResponse.decode), +}); diff --git a/app/src/store/saga/index.ts b/app/src/store/saga/index.ts index b0a2b43a..4e84d78b 100644 --- a/app/src/store/saga/index.ts +++ b/app/src/store/saga/index.ts @@ -8,13 +8,20 @@ import { showToast, signOut, showNotification, + load, + LoadPayload, } from "@app/store/actions"; +import { restSuccess } from "@app/store/api/rest"; +import { cacheCustomEmoji, loadCustomEmoji } from "@app/store/routes"; import gatewayFlow from "@app/store/saga/gateway"; import interpret from "@app/store/saga/interpret"; import pools from "@app/store/saga/pools"; import sessionFlow from "@app/store/saga/session"; import { LOCAL_STORAGE_KEY } from "@app/store/slices/session"; import { setLocalStorage } from "@app/utility"; +import { CustomEmoji } from "@app/utility/types"; +import { isRight } from "fp-ts/lib/Either"; +import { Errors } from "io-ts"; /** * Root saga @@ -27,6 +34,7 @@ export default function* saga(): SagaIterator { yield takeEvery(signOut.type, handleSignOut); yield takeEvery(showNotification.type, autoHideNotification); + yield takeEvery(restSuccess.type, testSaga) } /** @@ -54,3 +62,25 @@ function* handleSignOut(action: ReturnType): SagaIterator { yield put(showToast({ message: "Signed out" })); } } + +function* testSaga(action: ReturnType): SagaIterator { + console.log("test"); + if (loadCustomEmoji.match(action) || cacheCustomEmoji.match(action)) { + console.log(action.payload.response.emoji); + const decodeResult = CustomEmoji.decode(action.payload.response.emoji); + const entities = []; + if (isRight(decodeResult)) { + entities.push(decodeResult.right as CustomEmoji); + } + yield put(load({ + type: 'customEmoji', + guildId: action.payload.routeData.guildId, + finished: true, + nonexistant: [], + entities: entities, + method: 'partial', + requestId: 200, + } as LoadPayload)) + //yield put(load(action.payload.emoji)); + } +} \ No newline at end of file diff --git a/app/src/store/slices/emojiManager.ts b/app/src/store/slices/emojiManager.ts new file mode 100644 index 00000000..1ce26ae7 --- /dev/null +++ b/app/src/store/slices/emojiManager.ts @@ -0,0 +1,38 @@ +import { createSlice } from "@reduxjs/toolkit"; + +import { restSuccess } from "@app/store/api/rest"; +import { cacheCustomEmoji, loadCustomEmoji } from "@app/store/routes"; +import { Nil } from "@architus/lib/types"; +import { isDefined } from "@architus/lib/utility"; +import { HoarFrost } from "src/utility/types"; + + + +// ? ==================== +// ? Reducer exports +// ? ==================== + +const initialState: any = { }; +const slice = createSlice({ + name: "statistics", + initialState, + reducers: {}, + extraReducers: { + [restSuccess.type]: (state, action) => { + if (loadCustomEmoji.match(action)) { + const { guildId, emojiId } = action.payload.routeData; + const decoded = loadCustomEmoji.decode(action.payload); + if (decoded.isDefined()) { + if (isDefined(state.pools.specific[guildId as string].customEmoji[emojiId as string])) { + state.pools.specific[guildId as string].customEmoji[emojiId] = decoded.get; + } else { + return { }; + } + } + } + return state; + }, + }, +}); + +//export default slice.reducer; diff --git a/app/src/store/slices/index.ts b/app/src/store/slices/index.ts index 0d9ca088..46a8fcbe 100644 --- a/app/src/store/slices/index.ts +++ b/app/src/store/slices/index.ts @@ -7,6 +7,8 @@ import Loading from "@app/store/slices/loading"; import Notifications from "@app/store/slices/notifications"; import Pools from "@app/store/slices/pools"; import Session from "@app/store/slices/session"; +import Statistics from "@app/store/slices/statistics"; +//import EmojiManager from "@app/store/slices/emojiManager" const rootReducer = combineReducers({ session: Session, @@ -16,6 +18,8 @@ const rootReducer = combineReducers({ interpret: Interpret, gateway: Gateway, pools: Pools, + statistics: Statistics, +// emojiManager: EmojiManager, }); export default rootReducer; diff --git a/app/src/store/slices/pools.ts b/app/src/store/slices/pools.ts index 4fdc89fb..bc79d3f4 100644 --- a/app/src/store/slices/pools.ts +++ b/app/src/store/slices/pools.ts @@ -11,6 +11,8 @@ import { AutoResponse, HoarFrost, Member, + Channel, + CustomEmoji, } from "@app/utility/types"; import { Option, None, Some } from "@architus/lib/option"; @@ -19,6 +21,8 @@ export type AllPoolTypes = { user: User; autoResponse: AutoResponse; member: Member; + channel: Channel; + customEmoji: CustomEmoji; }; // Runtime io-ts types @@ -27,10 +31,17 @@ export const AllPoolTypes = { user: User, autoResponse: AutoResponse, member: Member, + channel: Channel, + customEmoji: CustomEmoji, }; export const guildAgnosticPools = ["user", "guild"] as const; -export const guildSpecificPools = ["autoResponse", "member"] as const; +export const guildSpecificPools = [ + "autoResponse", + "member", + "channel", + "customEmoji", +] as const; export const allPools: PoolType[] = [ ...guildAgnosticPools, ...guildSpecificPools, @@ -795,16 +806,28 @@ export function usePoolEntities( }; // Return the previous provider if it's the same - return previousProvidersRef.current.isDefined() && - shallowEqual(previousProvidersRef.current.get[index], newProvider) - ? previousProvidersRef.current.get[index] - : newProvider; + if (previousProvidersRef.current.isDefined()) { + const previousProviders = previousProvidersRef.current.get; + if (index < previousProviders.length) { + const previousProvider = previousProviders[index]; + if (shallowEqual(previousProvider, newProvider)) { + return previousProvider; + } + } + } + + return newProvider; }); // Return the entire old providers array if every element is the same // (effectively ensures the same array is returned if there are no changes) - return previousProvidersRef.current.isDefined() && + + if ( + previousProvidersRef.current.isDefined() && shallowEqual(previousProvidersRef.current.get, providers) - ? previousProvidersRef.current.get - : providers; + ) { + return previousProvidersRef.current.get; + } + previousProvidersRef.current = Option.from(providers); + return providers; } diff --git a/app/src/store/slices/statistics.ts b/app/src/store/slices/statistics.ts new file mode 100644 index 00000000..d89e6319 --- /dev/null +++ b/app/src/store/slices/statistics.ts @@ -0,0 +1,58 @@ +import { createSlice } from "@reduxjs/toolkit"; + +import { restSuccess } from "@app/store/api/rest"; +import { fetchStats } from "@app/store/routes"; +import { Nil } from "@architus/lib/types"; +import { isDefined } from "@architus/lib/utility"; +import { HoarFrost } from "src/utility/types"; + +/** + * Stores statistics for the guilds + */ +export interface GuildStatistics { + memberCount: number; + messageCount: number; + architusCount: number; + commonWords: Array<[string, number]>; + mentionCounts: Record; + memberCounts: Record; + channelCounts: Record; + timeMemberCounts: Record>; + upToDate: boolean; + forbidden: boolean; + lastActivity: string; + popularEmojis: Array; +} + +export interface Statistics { + statistics: Record | Nil; +} + +// ? ==================== +// ? Reducer exports +// ? ==================== + +const initialState: Statistics = { statistics: null }; +const slice = createSlice({ + name: "statistics", + initialState, + reducers: {}, + extraReducers: { + [restSuccess.type]: (state, action): Statistics => { + if (fetchStats.match(action)) { + const { guildId } = action.payload.routeData; + const decoded = fetchStats.decode(action.payload); + if (decoded.isDefined()) { + if (isDefined(state.statistics)) { + state.statistics[guildId as string] = decoded.get; + } else { + return { statistics: { [guildId]: decoded.get } }; + } + } + } + return state; + }, + }, +}); + +export default slice.reducer; diff --git a/app/src/tabs/AutoResponses/AutoResponses.tsx b/app/src/tabs/AutoResponses/AutoResponses.tsx index 57a0c630..60a16201 100644 --- a/app/src/tabs/AutoResponses/AutoResponses.tsx +++ b/app/src/tabs/AutoResponses/AutoResponses.tsx @@ -85,8 +85,7 @@ const AutoResponses: React.FC = (pageProps) => { return Array.from(ids); }, [commands]); const authorEntries = usePoolEntities({ - type: "member", - guildId: guild.id, + type: "user", ids: allAuthorIds, }); const authorsMap = useMemo(() => { @@ -161,11 +160,11 @@ function foldAuthorData( const id = autoResponse.authorId; const authorOption = id.flatMapNil((i) => authors.get(i)); if (authorOption.isDefined()) { - const { name, discriminator } = authorOption.get; + const { username, discriminator } = authorOption.get; return { - author: `${name}#${discriminator}|${id}`, + author: `${username}#${discriminator}|${id}`, avatarUrl: getAvatarUrl({ user: authorOption.get }) ?? "", - username: name, + username, discriminator, }; } diff --git a/app/src/tabs/EmojiManager.tsx b/app/src/tabs/EmojiManager.tsx deleted file mode 100644 index f767fe58..00000000 --- a/app/src/tabs/EmojiManager.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { styled } from "linaria/react"; -import React from "react"; - -import { appVerticalPadding, appHorizontalPadding } from "@app/layout"; -import { TabProps } from "@app/tabs/types"; -import Badge from "@architus/facade/components/Badge"; -import { color } from "@architus/facade/theme/color"; - -const Styled = { - Layout: styled.div` - padding: ${appVerticalPadding} ${appHorizontalPadding}; - `, - Title: styled.h2` - color: ${color("textStrong")}; - font-size: 1.9rem; - font-weight: 300; - `, -}; - -const EmojiManager: React.FC = () => ( - - - Emoji Manager Coming Soon - - -); - -export default EmojiManager; diff --git a/app/src/tabs/EmojiManager/EmojiChart.tsx b/app/src/tabs/EmojiManager/EmojiChart.tsx new file mode 100644 index 00000000..5b15f2c7 --- /dev/null +++ b/app/src/tabs/EmojiManager/EmojiChart.tsx @@ -0,0 +1,65 @@ +import { styled } from "linaria/react"; +import React from "react"; + +import HelpTooltip from "@app/components/HelpTooltip"; +import Switch from "@app/components/Switch"; +import { sitePadding } from "@app/layout"; +import { StylableButton } from "@architus/facade/components/Button"; +import Tooltip from "@architus/facade/components/Tooltip"; +import Comfy from "@architus/facade/icons/comfy.svg"; +import Compact from "@architus/facade/icons/compact.svg"; +import Sparse from "@architus/facade/icons/sparse.svg"; +import { color } from "@architus/facade/theme/color"; +import { up } from "@architus/facade/theme/media"; +import { shadow } from "@architus/facade/theme/shadow"; +import { gap } from "@architus/facade/theme/spacing"; +import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts"; + +const Styled = { + +}; + +export type EmojiChartProps = { + +}; + + +const data = [ + { name: 'Group A', value: 400 }, + { name: 'Group B', value: 300 }, + { name: 'Group C', value: 300 }, + { name: 'Group D', value: 200 }, +]; +const COLORS = ['#5850ba', '#844ea3', '#ba5095', '#ffbfa7']; + +/** + * Pie chart for displaying emoji manager usage stats + */ +const EmojiChart: React.FC = React.memo(({ + +}) => ( +
+ + + + {data.map((entry, index) => ( + + ))} + + + +
+)); + +export default EmojiChart; diff --git a/app/src/tabs/EmojiManager/EmojiChart2.tsx b/app/src/tabs/EmojiManager/EmojiChart2.tsx new file mode 100644 index 00000000..a617a4cd --- /dev/null +++ b/app/src/tabs/EmojiManager/EmojiChart2.tsx @@ -0,0 +1,150 @@ +import { styled } from "linaria/react"; +import React from "react"; + +import HelpTooltip from "@app/components/HelpTooltip"; +import Switch from "@app/components/Switch"; +import { sitePadding } from "@app/layout"; +import { StylableButton } from "@architus/facade/components/Button"; +import Comfy from "@architus/facade/icons/comfy.svg"; +import Compact from "@architus/facade/icons/compact.svg"; +import Sparse from "@architus/facade/icons/sparse.svg"; +import { color } from "@architus/facade/theme/color"; +import { down, up } from "@architus/facade/theme/media"; +import { shadow } from "@architus/facade/theme/shadow"; +import { gap } from "@architus/facade/theme/spacing"; +import { Bar, BarChart, ReferenceLine, ResponsiveContainer, XAxis, YAxis, Tooltip, Legend } from "recharts"; +import { load } from "src/store/actions"; +import { CustomRechartsTooltip } from "../Statistics/CustomRechartsTooltip"; +import { transition } from "@architus/facade/theme/motion"; + +const Styled = { + Outer: styled.div` + //width: 100%; + //height: 100px; + //display: flex; + justify-content: center; + align-items: center; + //margin-bottom: ${gap.nano}; + margin-top: -5px; + //padding-right: 58px; + ${up("md")} { + margin-left: calc(${gap.milli} - 5px); + margin-right: calc(${gap.milli} - 5px); + } + + ${down("md")} { + border-radius: 0; + } + `, + Bar: styled.div` + height: 100%; + width: 100%; + padding: 0 ${gap.milli}; + background-color: ${color('bg-10')}; + border-radius: 0 0 10px 10px; + //margin: 0 ${gap('nano')} ${gap('milli')} ${gap('milli')}; + display: flex; + justify-content: center; + align-items: center; + height: 50px; + //padding: 27px 20px 0px 20px; + padding: 0; + `, + TooltipContainer: styled.div` + background-color: ${color("tooltip")}; + border: 1px solid ${color("tooltip")}; + box-shadow: ${shadow("z3")}; + border-radius: 4px; + display: flex; + color: ${color("light")}; + flex-direction: column; + ${transition(["opacity"])} + font-size: 0.9rem; + padding: 10px; + z-index: 100; + position: relative; + `, +}; + +const tooltipRenderer = ( + payload: Array<{ current: number }>, + label: string +): JSX.Element => { + if (payload.length === 0) return <>; + return ( + <> +

yep

+ + ); +}; + + + +export type EmojiChartProps = { + current: number, + loaded: number, + limit: number | 'unlimited', + discordLimit: number, +}; + + + +const COLORS = ['#5850ba', '#844ea3', '#ba5095', '#ffbfa7']; + +/** + * Pie chart for displaying emoji manager usage stats + */ +const EmojiChart: React.FC = React.memo(({ + current, loaded, limit, discordLimit +}) => { + var tooltip: string; + const CustomTooltip = ({ active, payload }) => { + if (!active || !tooltip) return null + for (const bar of payload) + if (bar.dataKey === tooltip) + return { bar.name }
{ bar.value }
{((loaded / discordLimit) * 100).toFixed(1)}% of discord capacity
{capacity}% of architus capacity
+ return null + } + const architusLimit = limit === 'unlimited' ? Math.round(current / 50) * 50 + 50 : limit; + const capacity = limit === 'unlimited' ? 0 : ((current / architusLimit) * 100).toFixed(1); + const data = [ + { + name: 'Page A', + loaded: loaded, + cached: current - loaded, + free: architusLimit - current + }, + + ]; + + return ( + + + + + tick === discordLimit ? "Discord Limit (50)" : "Architus Limit (200)"} + scale="linear" + /> + + + + } isAnimationActive={false}/> + tooltip="loaded" }/> + tooltip="cached" }/> + tooltip="free" }/> + + + + ); +}); + +export default EmojiChart; diff --git a/app/src/tabs/EmojiManager/EmojiManager.tsx b/app/src/tabs/EmojiManager/EmojiManager.tsx new file mode 100644 index 00000000..77859c3a --- /dev/null +++ b/app/src/tabs/EmojiManager/EmojiManager.tsx @@ -0,0 +1,542 @@ +import { boolean } from "fp-ts"; +import { styled } from "linaria/react"; +import React, { MutableRefObject, useEffect, useMemo, useState } from "react"; +import { FaDownload, FaCheckCircle, FaUpload, FaTrash } from "react-icons/fa"; +import { useDispatch } from "react-redux"; + +import { AuthorData, Author } from "../AutoResponses/types"; +import GridHeader, { ViewMode, viewModes } from "./GridHeader"; +import ManagerJumbotron from "./ManagerJumbotron"; +import DataGrid from "@app/components/DataGrid"; +import PageTitle from "@app/components/PageTitle"; +import UserDisplay, { getAvatarUrl } from "@app/components/UserDisplay"; +import { appVerticalPadding, appHorizontalPadding } from "@app/layout"; +import { Dispatch } from "@app/store"; +import { cacheCustomEmoji, loadCustomEmoji } from "@app/store/routes"; +import { usePool, usePoolEntities } from "@app/store/slices/pools"; +import { useCurrentUser } from "@app/store/slices/session"; +import { TabProps } from "@app/tabs/types"; +import { HoarFrost, Snowflake, CustomEmoji, User } from "@app/utility/types"; +import AutoLink from "@architus/facade/components/AutoLink"; +import Button from "@architus/facade/components/Button"; +import { useColorMode } from "@architus/facade/hooks"; +import { Color, color, hybridColor } from "@architus/facade/theme/color"; +import { up } from "@architus/facade/theme/media"; +import { gap } from "@architus/facade/theme/spacing"; +import { None, Option, Some } from "@architus/lib/option"; +import { isDefined } from "@architus/lib/utility"; +import { Column, SortDirection } from "react-data-grid"; +import { padding } from "polished"; +import { API_BASE } from "@app/api"; +import EmojiChart from "./EmojiChart2"; +import Tooltip from "@architus/facade/components/Tooltip"; +import { CustomEmojiIcon } from "@app/components/CustomEmoji"; +import Dialog from "@architus/facade/components/Dialog"; + + +const Styled = { + Layout: styled.div` + padding: ${appVerticalPadding} ${appHorizontalPadding}; + `, + Title: styled.h2` + color: ${color("textStrong")}; + font-size: 1.9rem; + font-weight: 300; + margin-bottom: ${gap.nano}; + `, + ImageWrapper: styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 100%; + img { + max-width: 48px; + max-height: 48px; + width: auto; + height: auto; + } + `, + PageOuter: styled.div` + position: relative; + display: flex; + justify-content: stretch; + align-items: stretch; + flex-direction: column; + height: 100%; + + padding-top: ${gap.milli}; + `, + Header: styled.div` + padding: 0 ${gap.milli}; + h2 { + color: ${color("textStrong")}; + font-size: 1.9rem; + font-weight: 300; + margin-bottom: ${gap.nano}; + } + + p { + margin-bottom: ${gap.micro}; + } + `, + DataGridWrapper: styled.div` + position: relative; + flex-grow: 1; + display: flex; + justify-content: stretch; + align-items: stretch; + flex-direction: column; + background-color: ${color("bg")}; + overflow: hidden; + + ${up("md")} { + margin-left: ${gap.milli}; + border-top-left-radius: 1rem; + } + `, + AuthorWrapper: styled.div` + display: flex; + align-items: center; + height: 100%; + `, + Avatar: styled(UserDisplay.Avatar)` + position: relative; + display: flex; + align-items: center; + flex-shrink: 0; + `, + Name: styled.span` + margin-left: ${gap.femto}; + color: text; + font-weight: 600; + `, + ButtonWrapper: styled.div` + display: flex; + align-items: center; + height: 100%; + & > :not(:last-child) { + margin-right: ${gap("femto")}; + } + `, + UsesWrapper: styled.div` + display: flex; + justify-content: right; + height: 100%; + padding-right: ${gap('femto')}; + p { + font-size: 1.5em; + font-style: bold; + text-align: right; + } + `, + IconWrapper: styled.div` + display: flex; + height: 100%; + align-items: center; + justify-content: center; + color: ${color("success")}; + font-size: 1.2em; + padding: 4px 0; +`, +}; + +function centerHeader(item) { + return ( +
+ {item.column.name} +
+ ); +} + +function rightHeader(item: MutableRefObject) { + return ( +
+ {item.column.name} +
+ ); +} + +function creatBtn( + x: boolean, + author: boolean, + admin: boolean, + dispatch: Dispatch, + emojiId: HoarFrost, + guildId: Snowflake +) { + const colorMode = useColorMode(); + if (x == true) { + return ( + + + ); + } + return ( + + ); +} + +function loadedYN(x: boolean) { + if (x == true) { + return ( + + + + ); + } + return <>; +} + +function isAuthor(currentUser: Option, row: CustomEmoji): boolean { + if (currentUser.isDefined()) { + return currentUser.get.id === row.authorId.getOrElse(-1); + } + return false; +} + +const EmojiManager: React.FC = ({ guild }) => { + const { all: commands, isLoaded: hasLoaded } = usePool({ + type: "customEmoji", + guildId: guild.id, + }); + + const currentUser: Option = useCurrentUser(); + + // Load the authors from the commands (call the pool in a staggered manner) + const allAuthorIds = useMemo(() => { + const ids: Set = new Set(); + for (const command of commands) { + if (command.authorId.isDefined()) { + ids.add(command.authorId.get); + } + } + return Array.from(ids); + }, [commands]); + + const authorEntries = usePoolEntities({ + type: "member", + guildId: guild.id, + ids: allAuthorIds, + }); + + const authorsMap = useMemo(() => { + const authors: Map = new Map(); + for (const authorEntry of authorEntries) { + if (authorEntry.isLoaded && authorEntry.entity.isDefined()) { + authors.set(authorEntry.entity.get.id, authorEntry.entity.get); + } + } + return authors; + }, [authorEntries]); + + const [show, setShow] = useState(false); + + const mayManageEmojis = !!(guild.permissions & 1073741824); + const colorMode = useColorMode(); + + const columns: Column[] = [ + { + key: "loaded", + name: "LOADED", + width: 10, + sortable: true, + headerRenderer: centerHeader, + formatter: ({ row }: { row: CustomEmoji }) => ( + <> {loadedYN(row.discordId.isDefined())} + ), + }, + { + key: "url", + name: "IMAGE", + width: 100, + headerRenderer: centerHeader, + formatter: ({ row }: { row: CustomEmoji }) => ( +/* + + */ + + + + + ), + }, + { + key: "download", + name: "DOWNLOAD", + sortable: true, + formatter: ({ row }: { row: CustomEmoji }) => ( + <> + + {row.name} + + + ), + }, + { + key: "authorId", + name: "AUTHOR", + sortable: true, + formatter: ({ row }: { row: CustomEmoji }) => ( + + + {foldAuthorData(row, authorsMap).author} + + ), + }, + { + key: "numUses", + name: "USES", + sortable: true, + width: 10, + headerRenderer: rightHeader, + formatter: ({ row }: { row: CustomEmoji }) => ( + <> + +

{row.numUses}

+
+ + ), + }, + ]; + const dispatch = useDispatch(); + if (mayManageEmojis) { + columns.push( + { + key: "btns", + name: "MANAGE", + width: 150, + formatter: ({ row }: { row: CustomEmoji }) => ( + <> + + {creatBtn( + row.discordId.isDefined(), + isAuthor(currentUser, row), + guild.architus_admin, + dispatch, + row.id, + guild.id + )} + + + + + ), + }) + } + + const { all: emojiList } = usePool({ + type: "customEmoji", + guildId: guild.id, + }); + + const [filterSelfAuthored, setFilterSelfAuthored] = useState(false); + + const filteredList = useMemo(() => { + return emojiList.filter((value: CustomEmoji, index: number) => { + return !filterSelfAuthored || value.authorId.getOrElse(undefined) === currentUser.getOrElse(undefined)?.id; + }); + }, [emojiList, filterSelfAuthored]); + + type ColumnKey = "loaded" | "download" | "authorId" | "numUses" | "priority"; + + interface Sort { + column: ColumnKey; + direction: SortDirection; + } + const [sort, setSort] = useState>(Some({ column: "priority", direction: "ASC" })); + const getSortedRows = useMemo(() => { + if (sort.isDefined()) { + let sortedRows: CustomEmoji[] = [...filteredList]; + const { column } = sort.get; + switch (column) { + case "download": + sortedRows = sortedRows.sort((a, b) => a.name.localeCompare(b.name)); + break; + case "loaded": + sortedRows = sortedRows.sort((a, b) => a.discordId.isDefined() ? -1 : 1); + break; + case "authorId": + sortedRows = sortedRows.sort((a, b) => { + const name = (e: CustomEmoji) => (e.authorId.map(id => authorsMap.get(id)?.name ?? '').getOrElse('')); + return name(a).localeCompare(name(b)); + }); + break; + case "numUses": + sortedRows = sortedRows.sort((a, b) => a.numUses - b.numUses); + break; + default: + sortedRows = sortedRows.sort((a, b) => a.priority - b.priority) + } + return sort.get.direction === "DESC" ? sortedRows.reverse() : sortedRows; + } + return commands; + }, [filteredList, sort]); + + + const onSort = (column: string, direction: SortDirection): void => { + setSort( + direction !== "NONE" + ? Some({ column: column as ColumnKey, direction }) + : None); + }; + + interface ConfData { + enabled: boolean; + architus_limit: number | "unlimited"; + discord_limit: number; + }; + const [managerConf, onManagerConf] = useState({enabled: false, architus_limit: "unlimited", discord_limit: 50}) + useEffect(() => { + fetch(`${API_BASE}/emojis/${guild.id}/conf`, { credentials: "include" }) + .then(response => response.json()) + .then(data => onManagerConf(data)); + }, [guild]); + + return ( + <> + setShow(false)} + header="Delete this emoji?" + /> + + + +

Emoji Manager

+
+ e.discordId.isDefined()).length} + discordLimit={managerConf.discord_limit} + architusLimit={250} + docsLink="https://docs.archit.us/features/emoji-manager/" + onChangeEnabled={(): void => undefined} + /> + e.discordId.isDefined()).length} + //limit={250} + //discordLimit={managerConf.discord_limit} + limit={250} + discordLimit={managerConf.discord_limit} + /> + + { }} + /> + + rowHeight={52} + rows={getSortedRows || []} + sortColumn={sort.getOrElse(undefined)?.column} + sortDirection={sort.getOrElse(undefined)?.direction} + onSort={onSort} + columns={columns as readonly Column[]} + rowKey="id" + /> + +
+ + ); +}; + +export default EmojiManager; + +/** + * Performs the row transformation operation, resolving auto responses to the necessary + * fields for display + * @param customEmoji - Current row auto response object + * @param authors - Map of IDs to User objects to use for fast lookup + */ +function foldAuthorData( + customEmoji: CustomEmoji, + authors: Map +): AuthorData { + const id = customEmoji.authorId; + const authorOption = id.flatMapNil((i) => authors.get(i)); + if (authorOption.isDefined()) { + const { name, discriminator } = authorOption.get; + return { + author: `${name}#${discriminator}`, + avatarUrl: getAvatarUrl({ user: authorOption.get }) ?? "", + username: name, + discriminator, + }; + } + + return { + author: "unknown", + username: "unknown", + discriminator: "0000", + avatarUrl: "/img/unknown.png", + }; +} diff --git a/app/src/tabs/EmojiManager/EmojiTooltip.tsx b/app/src/tabs/EmojiManager/EmojiTooltip.tsx new file mode 100644 index 00000000..3bbf43ac --- /dev/null +++ b/app/src/tabs/EmojiManager/EmojiTooltip.tsx @@ -0,0 +1,84 @@ +import { styled } from "linaria/react"; +import React from "react"; + +import HelpTooltip from "@app/components/HelpTooltip"; +import Switch from "@app/components/Switch"; +import { sitePadding } from "@app/layout"; +import { StylableButton } from "@architus/facade/components/Button"; +import Tooltip from "@architus/facade/components/Tooltip"; +import Comfy from "@architus/facade/icons/comfy.svg"; +import Compact from "@architus/facade/icons/compact.svg"; +import Sparse from "@architus/facade/icons/sparse.svg"; +import { color } from "@architus/facade/theme/color"; +import { up } from "@architus/facade/theme/media"; +import { shadow } from "@architus/facade/theme/shadow"; +import { gap } from "@architus/facade/theme/spacing"; +import { transition } from "@architus/facade/theme/motion"; + +const BaseButton = StylableButton<"button">(); +const ViewModeButton = styled(BaseButton)` + &[data-active="true"] { + background-color: ${color("activeOverlay")} !important; + box-shadow: inset 0 3px 7px ${color("activeOverlay")} !important; + } + + svg { + transform: translateY(2px); + } +`; +const Styled = { + TooltipContainer: styled.div` + background-color: ${color("tooltip")}; + border: 1px solid ${color("tooltip")}; + box-shadow: ${shadow("z3")}; + border-radius: 4px; + display: flex; + color: ${color("light")}; + flex-direction: column; + ${transition(["opacity"])} + font-size: 0.9rem; + padding: 10px; + z-index: 100; + position: relative; + `, +}; + +export type GridHeaderProps = { + filterSelfAuthored: boolean; + onChangeFilterSelfAuthored: (newShow: boolean) => void; + addNewRowEnable: boolean; + onAddNewRow: () => void; + className?: string; + style?: React.CSSProperties; +}; + +/** + * Renders an elevated header at the top of the data grid, + * providing a set of options + */ +const GridHeader: React.FC = ({ + filterSelfAuthored, + onChangeFilterSelfAuthored, + className, + style, +}) => ( + + + + Filter by self-authored + + + + } + /> + +); + +export default GridHeader; diff --git a/app/src/tabs/EmojiManager/GridHeader.tsx b/app/src/tabs/EmojiManager/GridHeader.tsx new file mode 100644 index 00000000..8fce2986 --- /dev/null +++ b/app/src/tabs/EmojiManager/GridHeader.tsx @@ -0,0 +1,124 @@ +import { styled } from "linaria/react"; +import React from "react"; + +import HelpTooltip from "@app/components/HelpTooltip"; +import Switch from "@app/components/Switch"; +import { sitePadding } from "@app/layout"; +import { StylableButton } from "@architus/facade/components/Button"; +import Tooltip from "@architus/facade/components/Tooltip"; +import Comfy from "@architus/facade/icons/comfy.svg"; +import Compact from "@architus/facade/icons/compact.svg"; +import Sparse from "@architus/facade/icons/sparse.svg"; +import { color } from "@architus/facade/theme/color"; +import { up } from "@architus/facade/theme/media"; +import { shadow } from "@architus/facade/theme/shadow"; +import { gap } from "@architus/facade/theme/spacing"; + +const BaseButton = StylableButton<"button">(); +const ViewModeButton = styled(BaseButton)` + &[data-active="true"] { + background-color: ${color("activeOverlay")} !important; + box-shadow: inset 0 3px 7px ${color("activeOverlay")} !important; + } + + svg { + transform: translateY(2px); + } +`; +const Styled = { + GridHeader: styled.div` + display: flex; + min-height: ${gap.centi}; + align-items: center; + justify-content: flex-start; + flex-wrap: wrap; + z-index: 4; + + box-shadow: ${shadow("z0")}; + background-color: ${color("bg+10")}; + padding: ${gap.femto} 0; + + & > * { + margin-top: ${gap.femto}; + margin-bottom: ${gap.femto}; + } + + ${up("md")} { + border-top-left-radius: 1rem; + } + `, + ViewModeButtonGroup: styled.div` + padding: 0 0.25rem; + border-radius: 0.5rem; + margin-left: auto; + + ${up("lg")} { + margin-right: ${sitePadding}; + } + + & > * { + &:not(:first-of-type) ${ViewModeButton} { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + &:not(:last-of-type) ${ViewModeButton} { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } + `, + ViewModeButton, + ViewModeTooltip: styled(Tooltip)` + display: inline-block; + `, + FilterSwitch: styled(Switch)` + padding: 0 1rem; + `, + FilterSelfSwitch: styled(Switch)` + padding: 0 1rem; + `, + SelfAuthorLabel: styled.span` + margin-right: ${gap.nano}; + `, +}; + +export type GridHeaderProps = { + filterSelfAuthored: boolean; + onChangeFilterSelfAuthored: (newShow: boolean) => void; + addNewRowEnable: boolean; + onAddNewRow: () => void; + className?: string; + style?: React.CSSProperties; +}; + +/** + * Renders an elevated header at the top of the data grid, + * providing a set of options + */ +const GridHeader: React.FC = ({ + filterSelfAuthored, + onChangeFilterSelfAuthored, + className, + style, +}) => ( + + + + Filter by self-authored + + + + } + /> + +); + +export default GridHeader; diff --git a/app/src/tabs/EmojiManager/ManageButton.tsx b/app/src/tabs/EmojiManager/ManageButton.tsx new file mode 100644 index 00000000..6ba047aa --- /dev/null +++ b/app/src/tabs/EmojiManager/ManageButton.tsx @@ -0,0 +1,72 @@ +import { styled } from "linaria/react"; +import React from "react"; + +import HelpTooltip from "@app/components/HelpTooltip"; +import Switch from "@app/components/Switch"; +import { sitePadding } from "@app/layout"; +import Button, { StylableButton } from "@architus/facade/components/Button"; +import Tooltip from "@architus/facade/components/Tooltip"; +import Comfy from "@architus/facade/icons/comfy.svg"; +import Compact from "@architus/facade/icons/compact.svg"; +import Sparse from "@architus/facade/icons/sparse.svg"; +import { color, hybridColor } from "@architus/facade/theme/color"; +import { up } from "@architus/facade/theme/media"; +import { shadow } from "@architus/facade/theme/shadow"; +import { gap } from "@architus/facade/theme/spacing"; +import { useColorMode } from "@architus/facade/hooks"; +import { FaUpload } from "react-icons/fa"; +import { useDispatch } from "react-redux"; +import { cacheCustomEmoji, loadCustomEmoji } from "@app/store/routes"; +import { HoarFrost, Snowflake } from "src/utility/types"; + + +const Styled = { + IconWrapper: styled.div` + display: flex; + height: 100%; + align-items: center; + justify-content: center; + color: ${color("success")}; + font-size: 1.2em; + padding: 4px 0; + `, +}; + +type ButtonType = "load" | "cache" | "delete"; + +export type ManageButtonProps = { + type: ButtonType, + disabled: boolean, + guildId: Snowflake, + emojiId: HoarFrost, +}; + +/** + * Button for using in emoji manager data grid + */ +const ManageButton: React.FC = React.memo(({ + type, disabled, guildId, emojiId +}) => { + const colorMode = useColorMode(); + const dispatch = useDispatch(); + return ( + +)}); + +export default ManageButton; diff --git a/app/src/tabs/EmojiManager/ManagerJumbotron.tsx b/app/src/tabs/EmojiManager/ManagerJumbotron.tsx new file mode 100644 index 00000000..b8be3b98 --- /dev/null +++ b/app/src/tabs/EmojiManager/ManagerJumbotron.tsx @@ -0,0 +1,152 @@ +import { styled } from "linaria/react"; +import React from "react"; + +import HelpTooltip from "@app/components/HelpTooltip"; +import Switch from "@app/components/Switch"; +import { sitePadding } from "@app/layout"; +import AutoLink from "@architus/facade/components/AutoLink"; +import Button, { StylableButton } from "@architus/facade/components/Button"; +import Tooltip from "@architus/facade/components/Tooltip"; +import Comfy from "@architus/facade/icons/comfy.svg"; +import Compact from "@architus/facade/icons/compact.svg"; +import Sparse from "@architus/facade/icons/sparse.svg"; +import { color } from "@architus/facade/theme/color"; +import { down, up } from "@architus/facade/theme/media"; +import { shadow } from "@architus/facade/theme/shadow"; +import { gap } from "@architus/facade/theme/spacing"; +import EmojiChart from "./EmojiChart"; + +const Section = styled.div` + background-color: ${color("bg-10")}; + display: flex; + flex-direction: column; + padding: 0 var(--padding); + align-items: flex-start; +`; + +const Styled = { + Outer: styled.div` + border-radius: ${gap.pico} ${gap.pico} 0 0; + //margin: 0 0 ${gap.micro}; + overflow: hidden; + display: flex; + flex-direction: row; + align-items: stretch; + + ${up("md")} { + margin-left: ${gap.milli}; + margin-right: ${gap.milli}; + } + + ${down("md")} { + border-radius: 0; + } + `, + Left: styled.div` + background-color: ${color("bg-10")}; + display: grid; + --padding: ${gap.micro}; + padding: var(--padding) 0; + `, + RightFill: styled.div` + background-color: ${color("bg-10")}; + opacity: 0.6; + flex-basis: 0; + flex-shrink: 0; + flex-grow: 1; + `, + SectionGrid: styled.div` + background-color: ${color("border")}; + max-width: 740px; + width: 100%; + display: grid; + grid-auto-flow: row; + + grid-template-columns: 3fr 3fr 5fr 5fr 5fr; + grid-template-rows: auto; + column-gap: 1px; + + ${down("md")} { + grid-template-columns: 3fr 5fr 5fr; + } + `, + TopRowSectionOnMedium: styled(Section)` + ${down("md")} { + padding-bottom: ${gap.micro}; + } + `, + EnabledSection: styled(Section)` + ${down("md")} { + grid-column: 1; + grid-row: 1 / 3; + } + `, + Section, + SectionTitle: styled.h4` + font-size: 1.1rem; + margin-bottom: ${gap.pico}; + `, + Count: styled.span` + font-weight: 600; + display: inline-block; + background-color: ${color("activeOverlay")}; + /* font-size: 1.1rem; */ + padding: 0.1rem 0.4rem; + border-radius: 4px; + `, +}; + +export type ManagerJumbotronProps = { + docsLink: string; + enabled: boolean; + onChangeEnabled: (next: boolean) => boolean; + current: number; + loaded: number; + discordLimit: number; + architusLimit: number | "unlimited"; +}; + +/** + * Big banner thing at the top of the emoji manager page for extra info and controls. + */ +export default function ManagerJumbotron({ + docsLink, + enabled, + onChangeEnabled, + current, + loaded, + discordLimit, + architusLimit, +}: ManagerJumbotronProps): React.ReactElement { + return ( + + + + + Enable + + + + Total + {current} + + + Loaded +
+ {loaded} / {discordLimit} +
+
+ + Architus Limit + {architusLimit} + + + More Info + docs.archit.us + +
+
+ +
+ ); +} diff --git a/app/src/tabs/EmojiManager/index.ts b/app/src/tabs/EmojiManager/index.ts new file mode 100644 index 00000000..2d4057bf --- /dev/null +++ b/app/src/tabs/EmojiManager/index.ts @@ -0,0 +1,2 @@ +// Convenience export +export { default } from "./EmojiManager"; diff --git a/app/src/tabs/Statistics.tsx b/app/src/tabs/Statistics.tsx deleted file mode 100644 index 9d95d34b..00000000 --- a/app/src/tabs/Statistics.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { styled } from "linaria/react"; -import React from "react"; - -import { appVerticalPadding, appHorizontalPadding } from "@app/layout"; -import { TabProps } from "@app/tabs/types"; -import Badge from "@architus/facade/components/Badge"; -import { color } from "@architus/facade/theme/color"; - -const Styled = { - Layout: styled.div` - padding: ${appVerticalPadding} ${appHorizontalPadding}; - `, - Title: styled.h2` - color: ${color("textStrong")}; - font-size: 1.9rem; - font-weight: 300; - `, -}; - -const Statistics: React.FC = () => ( - - - Statistics Coming Soon - - -); - -export default Statistics; diff --git a/app/src/tabs/Statistics/ChannelGraph.tsx b/app/src/tabs/Statistics/ChannelGraph.tsx new file mode 100644 index 00000000..5f60ee90 --- /dev/null +++ b/app/src/tabs/Statistics/ChannelGraph.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { + BarChart, + Bar, + Legend, + XAxis, + YAxis, + CartesianGrid, + Tooltip, +} from "recharts"; + +import { CustomRechartsTooltip } from "./CustomRechartsTooltip"; +import CustomResponsiveContainer from "./CustomResponsiveContainer"; +import { Channel } from "@app/utility/types"; +import { isDefined, formatNum } from "@architus/lib/utility"; + +type ChannelData = { + name: string; + count: number; +}; +type ChannelGraphProps = { + channelCounts: Record; + channels: Map; +}; + +const getChannelData = ( + channelCounts: Record, + channels: Map +): ChannelData[] => { + const data: ChannelData[] = []; + Object.entries(channelCounts).forEach(([key, value]) => { + const channel = channels.get(key); + if (isDefined(channel)) { + data.push({ name: channel.name, count: value }); + } + }); + data.sort((a, b) => (a.count < b.count ? 1 : -1)); + return data.slice(0, 15); +}; + +const tooltipRenderer = ( + payload: Array<{ value: number }>, + label: string +): JSX.Element => { + return ( + <> +

#{label}

+ {formatNum(payload.length > 0 ? payload[0].value : 0)} messages + + ); +}; + +/** + * Render a bar graph displaying the total message count of each channel. + */ +export const ChannelGraph: React.FC = React.memo( + ({ channelCounts, channels }) => ( + + + + + + } + /> + + + + + ) +); diff --git a/app/src/tabs/Statistics/CustomRechartsTooltip.tsx b/app/src/tabs/Statistics/CustomRechartsTooltip.tsx new file mode 100644 index 00000000..502fa78b --- /dev/null +++ b/app/src/tabs/Statistics/CustomRechartsTooltip.tsx @@ -0,0 +1,50 @@ +import { styled } from "linaria/react"; +import React from "react"; + +import { color } from "@architus/facade/theme/color"; +import { transition } from "@architus/facade/theme/motion"; +import { shadow } from "@architus/facade/theme/shadow"; +import { isDefined } from "@architus/lib/utility"; + +const Styled = { + Container: styled.div` + background-color: ${color("tooltip")}; + border: 1px solid ${color("tooltip")}; + box-shadow: ${shadow("z3")}; + border-radius: 4px; + display: flex; + color: ${color("light")}; + flex-direction: column; + ${transition(["opacity"])} + font-size: 0.9rem; + padding: 10px; + `, +}; + +type CustomTooltipProps = { + renderer: ( + payload: Array<{ value: number; stroke: string; name: string }>, + label: string + ) => JSX.Element; + type?: string; + payload?: Array<{ value: number; stroke: string; name: string }>; + label?: string; + className?: string; + style?: React.CSSProperties; +}; + +/** + * Architus-style tooltip to be plugged into various rechart components. + */ +export const CustomRechartsTooltip: React.FC = ({ + renderer, + payload, + label, + ...props +}) => { + return ( + + {isDefined(payload) && isDefined(label) ? renderer(payload, label) : null} + + ); +}; diff --git a/app/src/tabs/Statistics/CustomResponsiveContainer.tsx b/app/src/tabs/Statistics/CustomResponsiveContainer.tsx new file mode 100644 index 00000000..ef6e27d1 --- /dev/null +++ b/app/src/tabs/Statistics/CustomResponsiveContainer.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { ResponsiveContainer } from "recharts"; + +// From https://github.com/recharts/recharts/issues/1767#issuecomment-598607012 +const CustomResponsiveContainer: React.FC> = (props) => ( +
+
+ +
+
+); + +export default CustomResponsiveContainer; diff --git a/app/src/tabs/Statistics/GrowthChart.tsx b/app/src/tabs/Statistics/GrowthChart.tsx new file mode 100644 index 00000000..735ee525 --- /dev/null +++ b/app/src/tabs/Statistics/GrowthChart.tsx @@ -0,0 +1,157 @@ +import React, { useMemo } from "react"; +import { AreaChart, Area, XAxis, Tooltip } from "recharts"; + +import { CustomRechartsTooltip } from "./CustomRechartsTooltip"; +import CustomResponsiveContainer from "./CustomResponsiveContainer"; +import { Snowflake, Member } from "@app/utility/types"; +import { formatNum } from "@architus/lib/utility"; + +type GrowthChartProps = { + members: Map; +}; + +const tooltipRenderer = ( + payload: Array<{ value: number }>, + label: string +): JSX.Element => { + return ( + <> +

+ {new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "long", + day: "2-digit", + }).format(new Date(label ?? 0))} +

+ {formatNum(payload.length > 0 ? payload[0].value : 0)} members + + ); +}; + +const dateFormatter = (tick: string): string => { + const options = { month: "short", day: "numeric" }; + return new Date(tick).toLocaleDateString("en-US", options); +}; + +type GrowthDataPoint = { + count: number; + date: number; +}; + +// Creates bin number from year/month/day +const packBin = (year: number, month: number, day: number): number => + year * 500 + month * 40 + day; +// Creates Unix timestamp from year/month/day +const unpackBin = (bin: number): number => + new Date( + Math.floor(bin / 500), + Math.floor((bin % 500) / 40), + (bin % 500) % 40 + ).getTime(); + +/** + * A simple area chart which displays the cumulative number of members of a guild at any point. + * Bins the data points to hasten rendering. + */ +export const GrowthChart: React.FC = ({ members }) => { + const data = useMemo(() => { + if (members.size === 0) { + return []; + } + + // Find the date range of the dataset + let minJoinedAt: null | number = null; + let maxJoinedAt: null | number = null; + members.forEach(({ joined_at: joinedAt }) => { + minJoinedAt = Math.min(minJoinedAt ?? joinedAt, joinedAt); + maxJoinedAt = Math.max(maxJoinedAt ?? joinedAt, joinedAt); + }); + const range = (maxJoinedAt ?? 0) - (minJoinedAt ?? 0); + + // Determine the adaptive bin width, + // and pack the year/month/day of the start of the bin into a single number + // Additionally, add a zero bin at the beginning of the guild's history + let toBin: (day: Date) => number; + let makeZeroBin: (day: Date) => number; + if (range <= 2_419_200_000) { + // range <= 4 weeks as milliseconds: bin by day + // use arbitrary numbers to avoid collisions + toBin = (day: Date): number => + packBin(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate()); + makeZeroBin = (firstTimestamp: Date): number => { + firstTimestamp.setDate(firstTimestamp.getDate() - 1); + return toBin(firstTimestamp); + }; + } else if (range <= 10_368_000_000) { + // range <= 4 months as milliseconds: bin by week + toBin = (day: Date): number => + packBin( + day.getUTCFullYear(), + day.getUTCMonth(), + // Create 1-based day-of-month for the start of the week bin + Math.floor((day.getUTCDate() - 1) / 7) * 7 + 1 + ); + makeZeroBin = (firstTimestamp: Date): number => { + firstTimestamp.setDate(firstTimestamp.getDate() - 7); + return toBin(firstTimestamp); + }; + } else { + // bin by month + toBin = (day: Date): number => + // The start of the bin is always the start of the month + packBin(day.getUTCFullYear(), day.getUTCMonth(), 1); + makeZeroBin = (firstTimestamp: Date): number => { + firstTimestamp.setMonth(firstTimestamp.getMonth() - 1); + return toBin(firstTimestamp); + }; + } + + // Bin the data points by the adaptive bin width + const memberBins = new Map(); + members.forEach(({ joined_at: joinedAt }) => { + const joinedBin = toBin(new Date(joinedAt)); + memberBins.set(joinedBin, (memberBins.get(joinedBin) ?? 0) + 1); + }); + + // Create an unsorted list of data points + const dataPoints: GrowthDataPoint[] = []; + memberBins.forEach((count, bin) => { + dataPoints.push({ count, date: unpackBin(bin) }); + }); + + // Add the zero bin-value + const zeroDate = unpackBin(makeZeroBin(new Date(minJoinedAt ?? 0))); + dataPoints.push({ count: 0, date: zeroDate }); + + // Sort the data points by date + dataPoints.sort((a, b) => a.date - b.date); + + // Aggregate the data points in-place + let total = 0; + dataPoints.forEach((dataPoint) => { + total += dataPoint.count; + // eslint-disable-next-line no-param-reassign + dataPoint.count = total; + }); + + return dataPoints; + }, [members]); + + return ( + + + + + } + /> + + + ); +}; diff --git a/app/src/tabs/Statistics/IntegrityAlert.tsx b/app/src/tabs/Statistics/IntegrityAlert.tsx new file mode 100644 index 00000000..3395f7a4 --- /dev/null +++ b/app/src/tabs/Statistics/IntegrityAlert.tsx @@ -0,0 +1,68 @@ +import { styled } from "linaria/react"; +import { transparentize } from "polished"; +import React, { useState, useCallback } from "react"; + +import CloseButton from "@app/components/CloseButton"; +import { color, staticColor } from "@architus/facade/theme/color"; +import { gap } from "@architus/facade/theme/spacing"; + +const Styled = { + Alert: styled.div` + display: block; + position: relative; + margin-top: calc(-1 * ${gap.pico}); + font-size: 0.95em; + padding: ${gap.nano} 48px ${gap.nano} ${gap.micro}; + color: ${color("textFade")}; + background-color: ${transparentize(0.85, staticColor("info"))}; + border: 1px solid ${transparentize(0.5, staticColor("info"))}; + border-radius: 6px; + `, + CloseButton: styled(CloseButton)` + position: absolute; + top: 0; + right: 0; + `, +}; + +export type IntegrityAlertProps = { + sKey: string; + message: string; + enabled: boolean; + className?: string; + style?: React.CSSProperties; +}; + +/** + * Shows an integrity alert to users upon their first visit until they dismiss the alert + */ +const IntegrityAlert: React.FC = ({ + sKey, + message, + enabled, + className, + style, +}) => { + const initialValue = window.localStorage.getItem(sKey) !== "true"; + const [show, setShow] = useState(initialValue); + const hide = useCallback((): void => { + setShow(false); + window.localStorage.setItem(sKey, "true"); + }, [setShow, sKey]); + + return ( + <> + {enabled && show && ( + + + Notice: {message} + + )} + + ); +}; + +export default IntegrityAlert; diff --git a/app/src/tabs/Statistics/MemberGraph.tsx b/app/src/tabs/Statistics/MemberGraph.tsx new file mode 100644 index 00000000..3abfe629 --- /dev/null +++ b/app/src/tabs/Statistics/MemberGraph.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { + BarChart, + Bar, + Legend, + XAxis, + YAxis, + CartesianGrid, + Tooltip, +} from "recharts"; + +import { CustomRechartsTooltip } from "./CustomRechartsTooltip"; +import CustomResponsiveContainer from "./CustomResponsiveContainer"; +import { NormalizedUserLike } from "@app/utility/types"; +import { formatNum, isDefined } from "@architus/lib/utility"; + +type MemberData = { + name: string; + count: number; +}; +type MemberGraphProps = { + memberCounts: Record; + members: (id: string) => NormalizedUserLike | undefined; +}; + +const getMemberData = (memberCounts: Record): MemberData[] => { + const data: MemberData[] = []; + Object.entries(memberCounts).forEach(([key, value]) => { + data.push({ name: key, count: value }); + }); + data.sort((a, b) => (a.count < b.count ? 1 : -1)); + return data.slice(0, 15); +}; + +/** + * Display a bar chart of the top 15 most active message senders. + */ +export const MemberGraph: React.FC = React.memo( + ({ memberCounts, members }) => { + const tooltipRenderer = ( + payload: Array<{ value: number }>, + label: string + ): JSX.Element => { + return ( + <> +

@{isDefined(members(label)) ? members(label)?.username : label}

+ {formatNum(payload.length > 0 ? payload[0].value : 0)} messages + + ); + }; + const tickFormatter = (tick: string): string => { + const member = members(tick); + return isDefined(member) ? member.username : tick; + }; + return ( + + + + + + } + /> + + + + + ); + } +); diff --git a/app/src/tabs/Statistics/MentionsChart.tsx b/app/src/tabs/Statistics/MentionsChart.tsx new file mode 100644 index 00000000..b9991f56 --- /dev/null +++ b/app/src/tabs/Statistics/MentionsChart.tsx @@ -0,0 +1,59 @@ +import { styled } from "linaria/react"; +import React from "react"; + +import { Mention } from "@app/components/Mention"; +import { Member, Snowflake } from "@app/utility/types"; +import { isDefined, formatNum } from "@architus/lib/utility"; + +const Styled = { + Container: styled.div` + display: flex; + font-size: 0.875rem; + `, + Mention: styled(Mention)` + white-space: nowrap; + text-overflow: ellipsis; + max-width: 85%; + overflow: hidden; + `, + Dots: styled.div` + flex: 1; + text-align: right; + `, +}; + +type MentionsChartProps = { + mentionCounts: Record; + members: Map; +}; + +/** + * Takes a map of members and their mention counts and renders the + * top 6 in a table as Mention components along with their count. + */ +export const MentionsChart: React.FC = ({ + mentionCounts, + members, + ...props +}) => { + const counts: Array<{ member: Member; count: number }> = []; + Object.entries(mentionCounts).forEach(([id, count]) => { + if (isDefined(members.get(id as Snowflake))) { + counts.push({ member: members.get(id as Snowflake) as Member, count }); + } + }); + counts.sort((a, b) => b.count - a.count); + + return ( + <> + {counts.slice(0, 5).map((m) => ( + +
+ +
+ {formatNum(m.count)} +
+ ))} + + ); +}; diff --git a/app/src/tabs/Statistics/PersonalMessagesChart.tsx b/app/src/tabs/Statistics/PersonalMessagesChart.tsx new file mode 100644 index 00000000..0cad4ce1 --- /dev/null +++ b/app/src/tabs/Statistics/PersonalMessagesChart.tsx @@ -0,0 +1,169 @@ +import { styled } from "linaria/react"; +import { lighten } from "polished"; +import React, { useState } from "react"; +import { + ResponsiveContainer, + PieChart, + Pie, + Cell, + Legend, + Sector, +} from "recharts"; + +import { User } from "@app/utility/types"; +import { mode, ColorMode } from "@architus/facade/theme/color"; +import { formatNum } from "@architus/lib/utility"; + +const Styled = { + OuterContainer: styled.div` + max-width: 100%; + height: 100%; + display: flex; + `, + Label: styled.span` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + word-wrap: break-word; + //display: inline-block; + max-width: 75%; + `, + Tooltip: styled.text` + font-weight: bold; + font-size: 0.75em; + ${mode(ColorMode.Dark)} { + text-shadow: 0 0 2px rgba(0, 0, 50, 0.5); + } + `, +}; + +export type PersonalMessagesChartProps = { + currentUser: User; + totalMessages: number; + memberCounts: Record; +}; + +type renderActiveShapeProps = { + cx: number; + cy: number; + innerRadius: number; + outerRadius: number; + startAngle: number; + endAngle: number; + fill: string; + value: number; +}; + +/** + * Custom render component for the inside of recharts PieChart to display an absolute value in the center. + */ +const renderActiveShape = (props: renderActiveShapeProps): JSX.Element => { + const { + cx, + cy, + innerRadius, + outerRadius, + startAngle, + endAngle, + fill, + value, + } = props; + return ( + + + {formatNum(value)} + + + + ); +}; + +/** + * Create a cute little pie chart to display the ratio of the user's messages to the guild's total + * Displays the actual values in the middle on mouseover. + */ +export const PersonalMessagesChart: React.FC = React.memo( + ({ currentUser, totalMessages, memberCounts }) => { + const me = memberCounts[currentUser.id as string] ?? 0; + const data = [ + { name: currentUser.username, value: me }, + { name: "other", value: totalMessages - me }, + ]; + const [state, setState] = useState({ activeIndex: -1 }); + + const formatter = (value: string, _: unknown): JSX.Element => { + return {value}; + }; + + const onPieEnter = (_: unknown, index: number): void => { + setState((s) => ({ + ...s, + activeIndex: index, + })); + }; + + const onMouseLeave = (_: unknown): void => { + setState((s) => ({ + ...s, + activeIndex: -1, + })); + }; + const onMouseEnter = (o: { value: string }): void => { + setState((s) => ({ ...s, activeIndex: o.value === "other" ? 1 : 0 })); + }; + return ( + + + + + + + + + + ); + } +); diff --git a/app/src/tabs/Statistics/Statistics.tsx b/app/src/tabs/Statistics/Statistics.tsx new file mode 100644 index 00000000..42ef1e6e --- /dev/null +++ b/app/src/tabs/Statistics/Statistics.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useMemo } from "react"; +import { createSelectorCreator, defaultMemoize } from "reselect"; + +import StatisticsDashboard from "./StatisticsDashboard"; +import { useDispatch, useSelector } from "@app/store/hooks"; +import { fetchStats } from "@app/store/routes"; +import { useCurrentUser } from "@app/store/slices/session"; +import { TabProps } from "@app/tabs/types"; +import { User } from "@app/utility/types"; +import { Option } from "@architus/lib/option"; +import { Statistics } from "src/store/slices/statistics"; + +/** + * Bootstrap StatisticsDashboard. + * Does a bit of garbage to pull the statistics data and memoize it, etc. + */ +const StatisticsProvider: React.FC = (tabProps) => { + const dispatch = useDispatch(); + const { guild } = tabProps; + const currentUser: Option = useCurrentUser(); + + const createDeepEqualSelector = createSelectorCreator( + defaultMemoize, + (a, b) => { + // eslint-disable-next-line eqeqeq + return a == b; + } + ); + + const coolSelecter = createDeepEqualSelector( + (state: { statistics: Statistics }) => state.statistics, + (statistics) => + statistics.statistics ? statistics.statistics[guild.id as string] : null + ); + + const storeStatistics = useSelector(coolSelecter); + useEffect(() => { + dispatch(fetchStats({ routeData: { guildId: guild.id } })); + }, [dispatch, guild.id]); + + const guildStats = useMemo(() => { + return Option.from(storeStatistics); + }, [storeStatistics]); + + if (currentUser.isDefined()) + return ( + + ); + + return null; +}; +export default React.memo(StatisticsProvider); diff --git a/app/src/tabs/Statistics/StatisticsDashboard.tsx b/app/src/tabs/Statistics/StatisticsDashboard.tsx new file mode 100644 index 00000000..93e5615e --- /dev/null +++ b/app/src/tabs/Statistics/StatisticsDashboard.tsx @@ -0,0 +1,572 @@ +import { css } from "linaria"; +import { styled } from "linaria/react"; +import React, { useMemo } from "react"; +import { + FaComments, + FaUsers, + FaDiscord, + FaPlus, + FaUserPlus, +} from "react-icons/fa"; +import ago from "s-ago"; + +import { ChannelGraph } from "./ChannelGraph"; +import { GrowthChart } from "./GrowthChart"; +import IntegrityAlert from "./IntegrityAlert"; +import { MemberGraph } from "./MemberGraph"; +import { MentionsChart } from "./MentionsChart"; +import { PersonalMessagesChart } from "./PersonalMessagesChart"; +import { TimeAreaChart } from "./TimeAreaChart"; +import { WordCloud, WordData } from "./WordCloud"; +import { CustomEmojiIcon } from "@app/components/CustomEmoji"; +import PageTitle from "@app/components/PageTitle"; +import { Timeline, TimelineItem } from "@app/components/Timeline"; +import { appVerticalPadding, appHorizontalPadding } from "@app/layout"; +import { usePool, usePoolEntities } from "@app/store/slices/pools"; +import { GuildStatistics } from "@app/store/slices/statistics"; +import { TabProps } from "@app/tabs/types"; +import { snowflakeToDate } from "@app/utility/discord"; +import { + Channel, + CustomEmoji, + Member, + Snowflake, + User, + Guild, + HoarFrost, + NormalizedUserLike, + normalizeUserLike, +} from "@app/utility/types"; +import Card from "@architus/facade/components/Card"; +import Logo from "@architus/facade/components/Logo"; +import { color } from "@architus/facade/theme/color"; +import { down, up } from "@architus/facade/theme/media"; +import { animation } from "@architus/facade/theme/motion"; +import { gap } from "@architus/facade/theme/spacing"; +import { Option } from "@architus/lib/option"; +import { isDefined, formatNum } from "@architus/lib/utility"; + +const Styled = { + PageOuter: styled.div` + position: relative; + display: flex; + justify-content: stretch; + align-items: stretch; + flex-direction: column; + padding: ${appVerticalPadding} ${appHorizontalPadding}; + ${down("md")} { + padding: ${gap.nano} ${gap.nano}; + } + `, + Title: styled.h2` + color: ${color("textStrong")}; + font-size: 1.9rem; + font-weight: 300; + `, + IntegrityAlert: styled(IntegrityAlert)` + margin: ${gap.nano} 0 0; + ${down("md")} { + margin: ${gap.femto} 0 0; + } + `, + Logo: styled(Logo.Symbol)` + font-size: 2em; + fill: ${color("light")}; + padding: 0px 20px; + display: flex; + align-items: center; + `, + HeaderCards: styled.div` + display: flex; + justify-content: space-around; + align-items: stretch; + flex-direction: row; + //margin: ${gap.pico} 0; + //gap: ${gap.nano}; + + ${down("lg")} { + flex-wrap: wrap; + } + + & > * { + margin-top: ${gap.nano}; + ${down("md")} { + margin-top: ${gap.femto}; + } + + opacity: 0; + ${animation("fadeIn")} + } + + ${[...Array(3)] + .map( + (_, i) => `& > :nth-child(${i + 1}) { + animation: fadeIn 0.5s ${i * 0.05 + 0.05}s linear forwards; + }` + ) + .join("\n")} + `, + CardContainer: styled.div` + display: grid; + height: 100%; + width: 100%; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + grid-auto-rows: 200px; + grid-auto-flow: dense; + gap: ${gap.nano}; + justify-items: stretch; + margin: ${gap.nano} 0 0; + + ${down("md")} { + margin: ${gap.femto} 0 0; + grid-template-columns: repeat(auto-fit, minmax(175px, 1fr)); + grid-auto-rows: 150px; + gap: ${gap.femto}; + } + + & > * { + opacity: 0; + ${animation("fadeIn")} + } + + // THIS MUST BE INCREASED IF NEW CARDS ARE ADDED + ${[...Array(9)] + .map( + (_, i) => `& > :nth-child(${i + 1}) { + animation: fadeIn 0.5s ${0.05 * i + 0.2}s linear forwards; + }` + ) + .join("\n")} + `, + ContentContainer: styled.div` + position: relative; + display: flex; + align-items: center; + flex-direction: row; + `, + FadeIn: css` + animation: fadeIn 2s linear forwards; + `, + LabelContainer: styled.div` + position: relative; + font-variant: smallcaps; + display: flex; + flex-direction: column; + justify-content: center; + margin-bottom: ${gap.nano}; + color: ${color("light")}; + `, + Description: styled.p` + margin-bottom: 0; + font-size: 0.9em; + margin-top: -${gap.pico}; + color: ${color("light")} !important; + `, + MessageCard: styled(Card)` + ${up("lg")} { + margin-right: ${gap.nano}; + } + border: none; + flex: 1 1 100%; + background-color: #5850ba; + background-image: linear-gradient(62deg, #5850ba 0%, #844ea3 100%); + padding: ${gap.nano}; + `, + ArchitusCard: styled(Card)` + border: none; + padding: ${gap.nano}; + flex: 1 1 100%; + background-color: #ba5095; + background-image: linear-gradient(62deg, #ba5095 0%, #ffbfa7 100%); + ${down("lg")} { + flex-basis: 40%; + } + ${down("md")} { + flex-basis: 100%; + } + `, + MemberCard: styled(Card)` + ${up("md")} { + margin-right: ${gap.nano}; + } + border: none; + padding: ${gap.nano}; + flex: 1 1 100%; + background-color: #844ea3; + background-image: linear-gradient(62deg, #844ea3 0%, #ba5095 100%); + ${down("lg")} { + flex-basis: 40%; + } + ${down("md")} { + flex-basis: 100%; + } + `, + Card: styled(Card)` + grid-column: span auto; + grid-row: span auto; + + ${down("md")} { + padding: ${gap.micro}; + } + + & > h4 { + margin: 0px; + } + `, + BigCard: styled(Card)` + grid-column: span 2; + grid-row: span 2; + ${down("md")} { + padding: ${gap.micro}; + } + `, + TallCard: styled(Card)` + grid-column: span 1; + grid-row: span 2; + ${down("md")} { + padding: ${gap.micro}; + } + `, + LongCard: styled(Card)` + grid-column: span 2; + grid-row: span 1; + ${down("md")} { + padding: ${gap.micro}; + } + `, + EmojiContainer: styled.div` + margin-top: ${gap.nano}; + display: grid; + max-width: 100%; + max-height: 100%; + grid-template-rows: 48px 48px; + grid-template-columns: minmax(48px, 1fr) minmax(48px, 1fr) minmax(48px, 1fr); + gap: ${gap.nano}; + ${down("md")} { + grid-template-rows: 36px 36px; + grid-template-columns: minmax(36px, 1fr) minmax(36px, 1fr) minmax( + 36px, + 1fr + ); + gap: ${gap.femto}; + } + `, + Image: styled.img` + width: 100px; + height: auto; + `, + P: styled.p` + font-size: 2.5em; + `, +}; + +const iconClass = css` + font-size: 2em; + color: ${color("light")}; + margin: 0px 20px; +`; + +type StatisticsDashboardProps = { + isArchitusAdmin: boolean; + currentUser: User; + stats: Option; + guild: Guild; +} & TabProps; + +const StatisticsDashboard: React.FC = ({ + stats, + currentUser, + guild, +}) => { + const memberEntries = usePoolEntities({ + type: "member", + guildId: guild.id, + ids: stats.isDefined() + ? Object.keys(stats.get.memberCounts).map((id) => id as Snowflake) + : [], + }); + const members = useMemo(() => { + const membersMap: Map = new Map(); + for (const memberEntry of memberEntries) { + if (memberEntry.isLoaded && memberEntry.entity.isDefined()) { + membersMap.set(memberEntry.entity.get.id, memberEntry.entity.get); + } + } + return membersMap; + }, [memberEntries]); + + const userEntries = usePoolEntities({ + type: "user", + ids: memberEntries.filter((m) => m.nonexistant).map((m) => m.id), + }); + + const users = useMemo(() => { + const usersMap: Map = new Map(); + for (const userEntry of userEntries) { + if (userEntry.isLoaded && userEntry.entity.isDefined()) { + usersMap.set(userEntry.entity.get.id, userEntry.entity.get); + } + } + return usersMap; + }, [userEntries]); + + const membersClosure = (id: string): NormalizedUserLike | undefined => { + let member: Member | User | undefined = members.get(id as Snowflake); + member = member ?? users.get(id as Snowflake); + return isDefined(member) ? normalizeUserLike(member) : undefined; + }; + + const { all: channelsPool } = usePool({ + type: "channel", + guildId: guild.id, + }); + + const emojiEntries = usePoolEntities({ + type: "customEmoji", + guildId: guild.id, + ids: stats.isDefined() ? stats.get.popularEmojis : [], + }); + const emojis = useMemo(() => { + const emojisMap: Map = new Map(); + for (const emojiEntry of emojiEntries) { + if (emojiEntry.isLoaded && emojiEntry.entity.isDefined()) { + emojisMap.set(emojiEntry.entity.get.id, emojiEntry.entity.get); + } + } + return emojisMap; + }, [emojiEntries]); + + const channels = useMemo(() => { + const map: Map = new Map(); + for (const channel of channelsPool) { + map.set(channel.id as string, channel); + } + return map; + }, [channelsPool]); + + const memberCount = useMemo((): number => { + return stats.isDefined() ? stats.get.memberCount : 0; + }, [stats]); + + const messageCount = useMemo((): number => { + return stats.isDefined() ? stats.get.messageCount : 0; + }, [stats]); + + const architusMessageCount = useMemo((): number => { + return stats.isDefined() ? stats.get.architusCount : 0; + }, [stats]); + + const lastSeen = useMemo((): Date => { + return new Date(stats.isDefined() ? stats.get.lastActivity : 1420070400000); + }, [stats]); + + const memberCounts = useMemo(() => { + return stats.isDefined() ? stats.get.memberCounts : {}; + }, [stats]); + + const channelCounts = useMemo(() => { + return stats.isDefined() ? stats.get.channelCounts : {}; + }, [stats]); + + const joinDate = useMemo((): Date => { + if (isDefined(currentUser) && isDefined(currentUser.id)) { + const member = members.get(currentUser.id as Snowflake); + if (isDefined(member)) return new Date(member.joined_at); + } + return snowflakeToDate(guild.id); + }, [currentUser, members, guild]); + + const bestEmoji = useMemo((): CustomEmoji[] => { + const urls = []; + if (stats.isDefined()) { + const { popularEmojis } = stats.get; + for (let i = 0; i < 6; i++) { + const e = emojis.get(popularEmojis[i]); + if (isDefined(e)) { + urls.push(e); + } + } + } + return urls; + }, [stats, emojis]); + + const getWords = (): Array => { + return stats.isDefined() + ? stats.get.commonWords.map((word) => { + return { text: word[0], value: word[1] }; + }) + : []; + }; + const memWords = useMemo(getWords, [stats]); + + const timeData = useMemo((): { + data: Array<{ [key: string]: number }>; + ids: Set; + } => { + const data: Array<{ [key: string]: number }> = []; + const ids: Set = new Set(); + if (stats.isDefined()) { + Object.entries(stats.get.timeMemberCounts).forEach(([date, rec]) => { + const obj: { [key: string]: number } = { date: Date.parse(date) }; + if (obj.date < new Date().getTime() - 30 * 86400000) { + return; + } + Object.entries(rec).forEach(([id, count]) => { + obj[id] = count; + ids.add(id); + }); + data.push(obj); + }); + } + data.sort((a, b) => a.date - b.date); + return { data, ids }; + }, [stats]); + + const mentionsChart = useMemo(() => { + if (stats.isDefined()) { + return ( + + ); + } + return <>no mentions; + }, [stats, members]); + + return ( + + + Statistics + + + + + + + + {formatNum(messageCount)} + Messages Sent + + + + + + + + + {formatNum(memberCount)} + Members + + + + + + + + + {formatNum(architusMessageCount)} + Commands Executed + + + + + + + +

Your Messages

+ +
+ + +

Popular Custom Emoji

+ + {bestEmoji.map((e) => ( + + ))} + +
+ + +

Messages over Time

+ +
+ + +

Messages by Member

+ +
+ + +

Timeline

+ + + You joined discord + + + {guild.name} was created + + + You joined {guild.name} + + + Last activity in {guild.name} + + +
+ + +

Server Growth

+ +
+ + +

Mentions

+ {mentionsChart} +
+ + +

Messages by Channel

+ +
+ +

Popular Words

+ +
+
+
+ ); +}; + +export default StatisticsDashboard; diff --git a/app/src/tabs/Statistics/TimeAreaChart.tsx b/app/src/tabs/Statistics/TimeAreaChart.tsx new file mode 100644 index 00000000..89858da6 --- /dev/null +++ b/app/src/tabs/Statistics/TimeAreaChart.tsx @@ -0,0 +1,142 @@ +import { mix } from "polished"; +import React from "react"; +import { + ResponsiveContainer, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + Area, + AreaChart, +} from "recharts"; + +import { CustomRechartsTooltip } from "./CustomRechartsTooltip"; +import { useColorMode } from "@architus/facade/hooks"; +import { ColorMode } from "@architus/facade/theme/color"; +import { isDefined } from "@architus/lib/utility"; +import { NormalizedUserLike } from "src/utility/types"; + +type TimeAreaChartProps = { + ids: Set; + data: Array<{ [key: string]: number }>; + members: (id: string) => NormalizedUserLike | undefined; +}; + +function gradArray(col1: string, col2: string, n: number): Array { + const grad = []; + for (let i = 0; i < n; i++) { + grad[i] = mix(i / n, col1, col2); + } + return grad; +} + +const dateFormatter = (tick: string): string => { + const options = { month: "short", day: "numeric" }; + return new Date(tick).toLocaleDateString("en-US", options); +}; + +/** + * Display a pretty stacked area chart of members and their message counts over time. + */ +export const TimeAreaChart: React.FC = React.memo( + ({ ids, data, members }) => { + const colors = gradArray("#ba5095", "#5850ba", ids.size); + const accum: React.ReactNode[] = []; + const lightMode = useColorMode() === ColorMode.Light; + let i = 0; + ids.forEach((member) => { + accum.push( + + ); + }); + + const tooltipRenderer = ( + payload: Array<{ value: number; stroke: string; name: string }>, + label: string + ): JSX.Element => { + let sum = 0; + const size = isDefined(payload) ? payload.length : 0; + const names = []; + const large = size > 10; + if (large) { + payload.sort((a, b) => a.value - b.value); + } + for (let j = size - 1; j >= 0; j--) { + const item = payload[j]; + const member = members(item.name); + if (item.value === 0 || !isDefined(member)) { + // eslint-disable-next-line no-continue + continue; + } + sum += payload[j].value; + names.push( +
+

+ {member.username} : {item.value} +

+
+ ); + if (j < size - 11) { + names.push( +
+ + {j} more not shown... + +
+ ); + break; + } + } + return ( + <> +

+ {new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "long", + day: "2-digit", + }).format(new Date(label ?? 0))} +

+ {names} +

Total: {sum}

+ + ); + }; + + return ( + + + + + + } + /> + {accum} + + + ); + } +); diff --git a/app/src/tabs/Statistics/WordCloud.tsx b/app/src/tabs/Statistics/WordCloud.tsx new file mode 100644 index 00000000..ba79caf7 --- /dev/null +++ b/app/src/tabs/Statistics/WordCloud.tsx @@ -0,0 +1,48 @@ +import { styled } from "linaria/react"; +import React from "react"; +import AutoSizer from "react-virtualized-auto-sizer"; +import ReactWordcloud, { Options } from "react-wordcloud"; + +import { color } from "@architus/facade/theme/color"; + +export type WordData = { + text: string; + value: number; +}; + +type WordCloudProps = { + words: Array; +}; + +const Styled = { + AutoSizer: styled(AutoSizer)` + width: 100%; + `, +}; + +/** + * 90-degree word cloud wrapped in an auto sizer. That's pretty much it. + */ +export const WordCloud: React.FC = React.memo(({ words }) => { + const options: Partial = { + rotations: 2, + rotationAngles: [-90, 0], + fontSizes: [5, 120], + enableOptimizations: true, + enableTooltip: false, + // I would like to use Renner* at some point but it's broken for now. + // fontFamily: "Renner*", + colors: [color("textStrong")], + }; + return ( + + {({ height, width }): JSX.Element => ( + + )} + + ); +}); diff --git a/app/src/tabs/Statistics/index.ts b/app/src/tabs/Statistics/index.ts new file mode 100644 index 00000000..3644ae79 --- /dev/null +++ b/app/src/tabs/Statistics/index.ts @@ -0,0 +1,2 @@ +// Convenience export +export { default } from "./Statistics"; diff --git a/app/src/tabs/definitions.ts b/app/src/tabs/definitions.ts index 0874e58d..11c946a5 100644 --- a/app/src/tabs/definitions.ts +++ b/app/src/tabs/definitions.ts @@ -22,7 +22,7 @@ const definitions: TabDefinition[] = [ name: "Statistics", icon: StatisticsIcon, component: Statistics, - tooltip: "Coming soon", + tooltip: "View statistics dashboard", }, { path: "responses", @@ -36,7 +36,7 @@ const definitions: TabDefinition[] = [ name: "Emoji Manager", icon: EmojiManagerIcon, component: EmojiManager, - tooltip: "Coming soon", + tooltip: "View and manage custom emoji", }, { path: "logs", diff --git a/app/src/utility/discord.ts b/app/src/utility/discord.ts index f667ebd1..0c0e21a1 100644 --- a/app/src/utility/discord.ts +++ b/app/src/utility/discord.ts @@ -4,6 +4,7 @@ import { Snowflake, MockUser, Guild } from "./types"; const architusId: Snowflake = "448546825532866560" as Snowflake; const architusAvatar = "99de1e495875fb5c27ba9ac7303b45b7"; +const DISCORD_EPOCH = BigInt(1420070400000); /** * Architus bot's mock user to display inside a Discord Mock @@ -36,3 +37,8 @@ export function isDiscordAdminWithoutArchitus(guild: Guild): boolean { !guild.has_architus ); } + +export function snowflakeToDate(id: Snowflake): Date { + // eslint-disable-next-line no-bitwise + return new Date(Number((BigInt(id) >> BigInt(22)) + DISCORD_EPOCH)); +} diff --git a/app/src/utility/types.ts b/app/src/utility/types.ts index 9d13c116..782b524a 100644 --- a/app/src/utility/types.ts +++ b/app/src/utility/types.ts @@ -164,6 +164,17 @@ export const TimeFromString = new t.Type( (a) => new Date(a).toISOString() ); +export const EpochFromString = new t.Type( + "EpochFromString", + (u): u is string => typeof u === "string", + (u, c) => + either.chain(t.string.validate(u, c), (n) => { + const d = new Date(n); + return isNaN(d.getTime()) ? t.failure(u, c) : t.success(d.toISOString()); + }), + (s) => Date.parse(s) +); + /** * Represents a NodeJS module with HMR enabled */ @@ -617,4 +628,19 @@ export type Role = t.TypeOf; export const Role = t.type({}); export type Channel = t.TypeOf; -export const Channel = t.type({}); +export const Channel = t.type({ + id: TSnowflake, + name: t.string, +}); + +const TCustomEmoji = t.type({ + id: THoarFrost, + name: t.string, + authorId: option(TSnowflake), + numUses: t.number, + discordId: option(TSnowflake), + priority: t.number, + url: t.string, +}); +export interface CustomEmoji extends t.TypeOf {} +export const CustomEmoji = alias(TCustomEmoji)(); diff --git a/architus.code-workspace b/architus.code-workspace index 85adf0df..bea2635a 100644 --- a/architus.code-workspace +++ b/architus.code-workspace @@ -19,6 +19,9 @@ { "name": ".shared", "path": ".shared" + }, + { + "path": ".github" } ] } diff --git a/design/src/components/Button.tsx b/design/src/components/Button.tsx index 27c5190a..51dac992 100644 --- a/design/src/components/Button.tsx +++ b/design/src/components/Button.tsx @@ -92,6 +92,11 @@ const typeClasses = { &:active { background-color: var(--button-active-color); } + + &:disabled { + opacity: 0.25; + pointer-events: none; + } `, outline: css` border: 2px solid var(--button-color); @@ -109,6 +114,11 @@ const typeClasses = { color: var(--button-foreground); background-color: var(--button-active-color); } + + &:disabled { + opacity: 0.25; + pointer-events: none; + } `, ghost: css` color: inherit; @@ -131,10 +141,18 @@ const typeClasses = { &:active { background-color: var(--overlay-active); } + + &:disabled { + opacity: 0.25; + pointer-events: none; + } `, } as const; const sizeClasses = { + compacter: css` + --button-padding: ${gap.femto}; + `, compact: css` --button-padding: ${gap.pico}; `, @@ -146,7 +164,7 @@ const sizeClasses = { `, } as const; -export type ButtonSize = "compact" | "normal" | "large"; +export type ButtonSize = "compact" | "normal" | "large" | "compacter"; export type ButtonType = "solid" | "outline" | "ghost"; type BaseProps = { diff --git a/design/src/components/Dialog.tsx b/design/src/components/Dialog.tsx new file mode 100644 index 00000000..ff127091 --- /dev/null +++ b/design/src/components/Dialog.tsx @@ -0,0 +1,225 @@ +import { styled } from "linaria/react"; +import React, { useState } from "react"; +import { Modal } from "react-overlays"; +import ResizeObserver from "react-resize-observer"; +import { CSSTransition } from "react-transition-group"; + +import { color, ColorMode, mode } from "../theme/color"; +import { down } from "../theme/media"; +import { + TransitionSpeed, + transition, + ease, + easeOutBack, +} from "../theme/motion"; +import { ZIndex } from "../theme/order"; +import { shadow } from "../theme/shadow"; +import { usePrevious } from "@architus/lib/hooks"; +import { Option, None } from "@architus/lib/option"; +import Button from "./Button"; +import { gap } from "@architus/facade/theme/spacing"; + +const fade = "lightbox-fade"; +const fadeZoom = "lightbox-fadeZoom"; +const speed = TransitionSpeed.Normal; +const Styled = { + Backdrop: styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: ${ZIndex.Dialog}; + background-color: ${color("modalOverlay")}; + + &.${fade}-appear { + opacity: 0; + } + &.${fade}-exit { + opacity: 1; + } + &.${fade}-appear-active { + opacity: 1; + } + &.${fade}-exit-active { + opacity: 0; + } + + &.${fade}-appear-active, &.${fade}-exit-active { + ${transition(["opacity"])} + } + `, + Dialog: styled(Modal)` + position: fixed; + top: 0; + left: 0; + z-index: ${ZIndex.Dialog}; + display: none; + width: 100%; + height: 100%; + overflow: hidden; + outline: 0; + + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + pointer-events: none; + + &.${fadeZoom}-appear { + opacity: 0; + transform: scale(0.25); + } + &.${fadeZoom}-exit { + opacity: 1; + transform: none; + } + &.${fadeZoom}-appear-active { + opacity: 1; + transform: none; + } + &.${fadeZoom}-exit-active { + opacity: 0; + transform: scale(0.25); + } + + &.${fadeZoom}-exit-active { + ${transition(["opacity", "transform"], { speed })} + } + + &.${fadeZoom}-appear-active { + transition: opacity ${speed}ms ${ease}, + transform ${speed}ms ${easeOutBack}; + } + `, + Box: styled.div` + width: 400px; + //height: 200px; + pointer-events: all; + background-color: ${color('bg')}; + border-radius: 8px; + display: flex; + flex-direction: column; + justify-content: center; + + ${mode(ColorMode.Light)} { + border: 1px solid ${color('border')}; + } + ${mode(ColorMode.Dark)} { + box-shadow: ${shadow('z3')} + } + + & > * { + display: flex; + padding: ${gap.nano} ${gap.nano}; + align-items: center; + } + + & > :nth-child(1) { + flex-grow: 1; + flex-basis: 63px; + + h2 { + color: ${color("textStrong")}; + font-size: 1.5rem; + font-weight: 300; + } + border-bottom: 1px solid ${color('border')}; + } + & > :nth-child(2) { + flex-grow: 8; + + + } + & > :nth-child(3) { + border-radius: 0 0 8px 8px; + background-color: ${color('bg-10')}; + justify-content: right; + border-top: 1px solid ${color('border')}; + flex-grow: 0; + flex-basis: 54px; + padding: ${gap.nano}; + & > :first-child { + margin-right: ${gap.nano}; + } + } + `, + Button: styled(Button)` + //height: 10px; + `, +}; + +export type LightboxProps = { + show: boolean; + onClose: () => void; + header: string; + className?: string; + style?: React.CSSProperties; +}; + +/** + * Used to show a full screen preview of an image + */ +const Dialog: React.FC = ({ + show, + onClose, + header, + className, + style, +}) => { + return ( + ( + + )} + > + +
+

{header}

+ +
+
+

Are you sure you wish to delete the emoji :james_bad: from both your discord server and architus servers?

+ +
+
+ Keep + Delete +
+
+
+ ); +}; + +export default Dialog; + +// ? ================= +// ? Helper components +// ? ================= + +const Fade: React.FC<{ + in: boolean; + appear?: boolean | undefined; + unmountOnExit?: boolean | undefined; +}> = ({ children, ...props }) => ( + + {children} + +); + +const FadeZoom: React.FC<{ + in: boolean; + appear?: boolean | undefined; + unmountOnExit?: boolean | undefined; +}> = ({ children, ...props }) => ( + + {children} + +); diff --git a/design/src/theme/motion.ts b/design/src/theme/motion.ts index 4d119171..cdd8369b 100644 --- a/design/src/theme/motion.ts +++ b/design/src/theme/motion.ts @@ -64,6 +64,14 @@ const animationDefinitions = { background-position: 1000px 0; } `, + fadeIn: ` + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + `, }; export type AnimationKey = keyof typeof animationDefinitions; diff --git a/design/src/theme/order.ts b/design/src/theme/order.ts index a4447a79..3f0946ed 100644 --- a/design/src/theme/order.ts +++ b/design/src/theme/order.ts @@ -6,6 +6,7 @@ export enum ZIndex { ModalOverlay = 800, Drawer = 899, Modal = 900, + Dialog = 905, Header = 1000, ModalDrawer = 1050, ModalDrawerButton = 1051, diff --git a/docs/content/features/auto-responses/eval_demo.png b/docs/content/features/auto-responses/eval_demo.png new file mode 100644 index 00000000..d86b6b7a Binary files /dev/null and b/docs/content/features/auto-responses/eval_demo.png differ diff --git a/docs/content/features/auto-responses/index.md b/docs/content/features/auto-responses/index.md index 16142cfc..9b583444 100644 --- a/docs/content/features/auto-responses/index.md +++ b/docs/content/features/auto-responses/index.md @@ -1,6 +1,7 @@ --- title: Automatic Responses shortTitle: Auto Responses +TOCDepth: 1 --- Auto responses allow users to configure architus to listen for and respond to message patterns using an extensive syntax explained below. @@ -81,6 +82,14 @@ substituted with the [captured text](/features/auto-responses/#capture-groups) t ![capture](./capture_demo.png) +#### \[eval script\] + +substituted with whatever is printed from the the starlark script that comes after the eval. Can also use `e` as a shorthand for `eval`. + +![eval](./eval_demo.png) + +See [here](/features/auto-responses/#eval) for more information on what can go into a script and how to write them. + ### Errors If architus is unable to parse your response, it will display an error along with the position of the character that confused it. @@ -180,6 +189,102 @@ In the event that architus is unable to parse your trigger regex, it will give a Non-admin users will not be able to set regex triggers unless they are allowed in `settings` +## Eval +Architus uses the [starlark](https://github.com/bazelbuild/starlark) language as its scripting language for auto-responses. A detailed specification of everything you can do in the language can be found [here](https://github.com/bazelbuild/starlark/blob/master/spec.md). + +Starlark is a stripped down dialect of Python. This means that most simple Python scripts will produce the exact same behavior in Starlark. + +### Usage +Anything printed in the script will appear in the response content. + +To print from starlark, the standard `print` function can be called. In addition, eval accepts a shorthand, `p`, to decrease the length of user scripts. + + + +Starlark has some peculiarities about it that make it more difficult to use when coming from Python. For example, strings are not iterable in Starlark, only lists. To iterate over a string you must call the `elems` method on it: + +```py +print([a for a in "aoua".elems()]) # prints: ["a", "o", "u", "a"] +``` + + + +### Builtin Functions +The architus implementation of Starlark adds in a few extra builtins to improve user experience. They are called the same as any other Python builtin with the same behavior. + +#### Random +The `random` function can be called with no arguments to return a random float in the range `[0,1)`: +```py +print(random()) # ex: 0.686645146372598 +``` + +#### Randint +The `randint` function can be called with a high and low parameter to return a random integer in that range. For instance, `randint(lo, hi)` will return an integer in the range `[lo, hi)`. + +#### Choice +The `choice` function will return a random element from a list. It accepts a single list element as an argument and its return type can be any of the types in the list: +```py +print(choice(["hello", 'a', 1, 1.2345, randint])) # ex: hello +``` + +#### Sine +The `sin` function is the standard trigonometric sine function. It accepts radians as an argument and will return a float in the range `[0, 1]`. + +#### Sum +The `sum` function will sum all of the elements in a list. The sum will always start at zero and all of the elements of the list must be a number: +```py +nums = range(1,101) +values = [1,1.5,3,4.5] +assert(sum(nums) == 5050) +assert(sum(values) == 10.0) +``` + +### Global Variables +Architus adds several global variables to the script environment to allow users to access information about the message, author, and channel that triggered the auto-response: + +* [message (msg)](#message) +* [author (a)](#author-1) +* [channel (ch)](#channel) +* [count](#count) +* [caps](#caps) + +Some global variables are objects. To access the `name` member of an `author` you can just use `author.name`. + +###### message + +| Member name | Type | Description | +| ------------- | --------- | ---------------------------- | +| id | integer | Discord id of the message | +| content | string | Raw content of the message | +| clean | string | Clean content of the message | + +###### author + +| Member name | Type | Description | +| ------------- | ----------------- | --------------------------------------------------------- | +| id | integer | Discord id of author | +| avatar\_url | string | Url for author's avatar | +| color | string | The color the author's name is displayed as | +| discrim | integer | The discriminator part of the author's username | +| roles | list of integers | Contains the discord ids for each role the author is in | +| name | string | Author's username with no discriminator | +| nick | string | Author's nickname in the server | +| disp | string | Author's display name in the server | + +###### channel + +| Member name | Type | Description | +| ------------- | ----------------- | ----------------------------- | +| id | integer | Discord id of the channel | +| name | string | Name of the channel | + +##### count +The number of times this auto response has been triggered. + +##### caps +An array of strings that contain all of the capture groups defined in the [regular expression trigger](#regex-triggers) of the auto response. + + ## Settings The auto response settings pane can be accessed by the `settings responses` command @@ -196,6 +301,8 @@ The auto response settings pane can be accessed by the `settings responses` comm | Response Response Length | int | the maximum length of responses that users may set | | Allow Trigger Collisions | bool | whether setting triggers that overshaddow each other is allowed | | Restrict Remove | bool | whether anyone may remove an auto response or just the author | +| Allow Embeds | bool | If false, architus will escape links in an auto response to prevent discord from embedding them | +| Allow Newlines | bool | Toggles whether or not architus will strip new lines in auto response outputs | diff --git a/docs/gatsby-node.ts b/docs/gatsby-node.ts index 58af6408..4163f7d5 100644 --- a/docs/gatsby-node.ts +++ b/docs/gatsby-node.ts @@ -242,6 +242,7 @@ export const createPages: GatsbyNode["createPages"] = async ({ breadcrumb, title, shortTitle, + TOCDepth: passthrough?.TOCDepth ?? 4, isOrphan: isNil(originalPath), noTOC: passthrough?.noTOC ?? false, noSequenceLinks: passthrough?.noSequenceLinks ?? false, diff --git a/docs/netlify.toml b/docs/netlify.toml index 04354739..e6d6eaf7 100644 --- a/docs/netlify.toml +++ b/docs/netlify.toml @@ -17,3 +17,4 @@ NODE_ENV = "production" GATSBY_PUBLIC = "true" UMAMI_WEBSITE_ID = "410143ac-62d7-4200-a4f5-3895fd36c182" + GITHUB_BRANCH = "develop" diff --git a/docs/src/components/TableOfContents.tsx b/docs/src/components/TableOfContents.tsx index 67b4cf9b..fde3db57 100644 --- a/docs/src/components/TableOfContents.tsx +++ b/docs/src/components/TableOfContents.tsx @@ -73,6 +73,7 @@ export type TableOfContentsNode = { export type TableOfContentsProps = { items: TableOfContentsNode[]; + maxDepth: number; className?: string; style?: React.CSSProperties; }; @@ -82,6 +83,7 @@ export type TableOfContentsProps = { */ const TableOfContents: React.FC = ({ items, + maxDepth, className, style, }) => { @@ -91,7 +93,7 @@ const TableOfContents: React.FC = ({
@@ -105,14 +107,22 @@ export default TableOfContents; // ? Helper components // ? ================= -const TocItems: React.FC<{ items: TableOfContentsNode[] }> = ({ items }) => ( +const TocItems: React.FC<{ + items: TableOfContentsNode[]; + maxDepth: number; + currentDepth?: number; +}> = ({ items, maxDepth, currentDepth = 0 }) => ( <> {items.map((item, i) => (
  • {item.title} - {isDefined(item.items) ? ( + {isDefined(item.items) && currentDepth < maxDepth ? (
      - +
    ) : null}
  • diff --git a/docs/src/data/umami-analytics.ts b/docs/src/data/umami-analytics.ts index ef506c62..12033428 100644 --- a/docs/src/data/umami-analytics.ts +++ b/docs/src/data/umami-analytics.ts @@ -12,6 +12,9 @@ export type UmamiAnalytics = { * Gets the site's Umami analytics parameters if they exist */ export function useUmamiAnalytics(): Option { + // this seems to be required due to a bug with useStaticQuery in gatsby v2.23.11 + if (process.env.NODE_ENV !== "production") return None; + // eslint-disable-next-line react-hooks/rules-of-hooks const queryResult = useStaticQuery< GatsbyTypes.UseUmamiAnalyticsQuery >(graphql` diff --git a/docs/src/templates/Docs/frontmatter.ts b/docs/src/templates/Docs/frontmatter.ts index 329aaa25..39f1c5be 100644 --- a/docs/src/templates/Docs/frontmatter.ts +++ b/docs/src/templates/Docs/frontmatter.ts @@ -15,6 +15,7 @@ export const frontmatterType = ` noBreadcrumb: Boolean # Passthrough props (add additional fields here) + TOCDepth: Int noTOC: Boolean badge: String noSequenceLinks: Boolean @@ -34,6 +35,7 @@ export const frontmatterFragment = ` noBreadcrumb # Passthrough props (add additional fields here) + TOCDepth noTOC badge noSequenceLinks @@ -65,6 +67,7 @@ export type DocsPassthroughProps = { noTOC?: boolean; noSequenceLinks?: boolean; badge?: string; + TOCDepth?: number; }; /** @@ -111,6 +114,7 @@ export type DocsPage = { breadcrumb: BreadcrumbSegment[] | Nil; title: string; shortTitle: string; + TOCDepth: number; isOrphan: boolean; noTOC: boolean; noSequenceLinks: boolean; @@ -125,6 +129,7 @@ export const docsPageType = ` breadcrumb: [BreadcrumbSegment!] title: String! shortTitle: String! + TOCDepth: Int! isOrphan: Boolean! noTOC: Boolean! noSequenceLinks: Boolean! diff --git a/docs/src/templates/Docs/index.tsx b/docs/src/templates/Docs/index.tsx index 0aad9ace..6e08fde2 100644 --- a/docs/src/templates/Docs/index.tsx +++ b/docs/src/templates/Docs/index.tsx @@ -145,6 +145,7 @@ const Docs: React.FC {!noTOC && tableOfContents.length > 0 && ( - + )} @@ -225,6 +226,7 @@ export const query = graphql` page: docsPage(id: { eq: $id }) { title shortTitle + TOCDepth badge isOrphan noTOC diff --git a/lib/src/utility/date.ts b/lib/src/utility/date.ts index 27b37bbc..b8254a1c 100644 --- a/lib/src/utility/date.ts +++ b/lib/src/utility/date.ts @@ -14,3 +14,37 @@ export function formatDate(date: Date, separator = "at"): string { const timeString = date.toLocaleTimeString(lang, {}); return `${dateString} ${separator} ${timeString}`; } + +/** + * Pretty-prints a date using locale-aware formatting + * @param date - Source date to format + */ +export function formatDateShort(date: Date, separator = "at"): string { + const lang = + typeof window === "undefined" ? "en-US" : window.navigator.languages[0]; + const dateString = date.toLocaleDateString(lang, { + year: "numeric", + month: "short", + day: "numeric", + }); + const timeString = date.toLocaleTimeString(lang, { + hour: "numeric", + minute: "2-digit", + }); + return `${dateString} ${separator} ${timeString}`; +} + +/** + * Pretty-prints a date using locale-aware formatting + * @param date - Source date to format + */ +export function formatDateExtraShort(date: Date): string { + const lang = + typeof window === "undefined" ? "en-US" : window.navigator.languages[0]; + const dateString = date.toLocaleDateString(lang, { + year: "numeric", + month: "short", + day: "numeric", + }); + return dateString; +} diff --git a/lib/src/utility/index.ts b/lib/src/utility/index.ts index e9ca83fd..8429d23a 100644 --- a/lib/src/utility/index.ts +++ b/lib/src/utility/index.ts @@ -2,3 +2,4 @@ export * from "./date"; export * from "./primitive"; +export * from "./number"; diff --git a/lib/src/utility/number.ts b/lib/src/utility/number.ts new file mode 100644 index 00000000..b88ed49e --- /dev/null +++ b/lib/src/utility/number.ts @@ -0,0 +1,9 @@ +/** + * Format number using locale-aware formatting + * @param number - Source number to format + */ +export function formatNum(num: number): string { + const lang = + typeof window === "undefined" ? "en-US" : window.navigator.languages[0]; + return num.toLocaleString(lang); +} diff --git a/package.json b/package.json index 9a507beb..4e59f216 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,11 @@ "bootstrap": "lerna bootstrap" }, "dependencies": { + "babel-plugin-module-resolver": "^4.0.0", "graphql": "^14.7.0", - "typescript": "^3.9.5", "lerna": "^3.22.1", - "babel-plugin-module-resolver": "^4.0.0" + "typescript": "^3.9.5", + "yarn": "^1.22.10" }, "devDependencies": { "@babel/core": "^7.1.0", diff --git a/yarn.lock b/yarn.lock index 68a977b3..f9e68d26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1038,7 +1038,7 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": version "7.11.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== @@ -3307,6 +3307,18 @@ resolved "https://registry.yarnpkg.com/@types/configstore/-/configstore-2.1.1.tgz#cd1e8553633ad3185c3f2f239ecff5d2643e92b6" integrity sha1-zR6FU2M60xhcPy8jns/10mQ+krY= +"@types/d3-path@^1": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-1.0.9.tgz#73526b150d14cd96e701597cbf346cfd1fd4a58c" + integrity sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ== + +"@types/d3-shape@^1": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-1.3.5.tgz#c0164c1be1429473016f855871d487f806c4e968" + integrity sha512-aPEax03owTAKynoK8ZkmkZEDZvvT4Y5pWgii4Jp4oQt0gH45j6siDl9gNDVC5kl64XHN2goN9jbYoHK88tFAcA== + dependencies: + "@types/d3-path" "^1" + "@types/debug@^0.0.30": version "0.0.30" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-0.0.30.tgz#dc1e40f7af3b9c815013a7860e6252f6352a84df" @@ -3606,6 +3618,14 @@ "@types/prop-types" "*" csstype "^2.2.0" +"@types/recharts@^1.8.18": + version "1.8.19" + resolved "https://registry.yarnpkg.com/@types/recharts/-/recharts-1.8.19.tgz#047f72cf4c25df545aa1085fe3a085e58a2483c1" + integrity sha512-Fd2cYnBlWz/ga8rLmjwsZNBAc4CzXZiuTYPPqMIgrtQ02yI/OTm8WPM6ZyUuYlSdyangtsvFmHWzZ7W4tuknDA== + dependencies: + "@types/d3-shape" "^1" + "@types/react" "*" + "@types/rimraf@^2.0.2": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/rimraf/-/rimraf-2.0.4.tgz#403887b0b53c6100a6c35d2ab24f6ccc042fec46" @@ -4836,6 +4856,11 @@ bail@^1.0.0: resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.5.tgz#b6fa133404a392cbc1f8c4bf63f5953351e7a776" integrity sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ== +balanced-match@^0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" + integrity sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg= + balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" @@ -5793,6 +5818,11 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" +classnames@^2.2.5: + version "2.2.6" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" + integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== + clean-stack@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" @@ -6379,7 +6409,7 @@ core-js-pure@^3.0.0: resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813" integrity sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA== -core-js@^2.4.0, core-js@^2.4.1, core-js@^2.6.11, core-js@^2.6.5: +core-js@^2.4.0, core-js@^2.4.1, core-js@^2.6.10, core-js@^2.6.11, core-js@^2.6.5: version "2.6.11" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== @@ -6423,6 +6453,11 @@ cosmiconfig@^5.0.0, cosmiconfig@^5.1.0: js-yaml "^3.13.1" parse-json "^4.0.0" +countup.js@^1.9.3: + version "1.9.3" + resolved "https://registry.yarnpkg.com/countup.js/-/countup.js-1.9.3.tgz#ce3e50cd7160441e478f07da31895edcc0f1c9dd" + integrity sha1-zj5QzXFgRB5HjwfaMYle3MDxyd0= + create-ecdh@^4.0.0: version "4.0.4" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e" @@ -6776,6 +6811,161 @@ cyclist@^1.0.1: resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= +"d3-array@1.2.0 - 2", d3-array@^2.5.0: + version "2.7.1" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-2.7.1.tgz#b1f56065e9aba1ef6f0d0c8c9390b65421593352" + integrity sha512-dYWhEvg1L2+osFsSqNHpXaPQNugLT4JfyvbLE046I2PDcgYGFYc0w24GSJwbmcjjZYOPC3PNP2S782bWUM967Q== + +d3-array@^1.2.0: + version "1.2.4" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f" + integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw== + +d3-cloud@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/d3-cloud/-/d3-cloud-1.2.5.tgz#3e91564f2d27fba47fcc7d812eb5081ea24c603d" + integrity sha512-4s2hXZgvs0CoUIw31oBAGrHt9Kt/7P9Ik5HIVzISFiWkD0Ga2VLAuO/emO/z1tYIpE7KG2smB4PhMPfFMJpahw== + dependencies: + d3-dispatch "^1.0.3" + +d3-collection@1: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e" + integrity sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A== + +d3-color@1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a" + integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q== + +"d3-color@1 - 2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-2.0.0.tgz#8d625cab42ed9b8f601a1760a389f7ea9189d62e" + integrity sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ== + +d3-dispatch@1, d3-dispatch@^1.0.3, d3-dispatch@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.6.tgz#00d37bcee4dd8cd97729dd893a0ac29caaba5d58" + integrity sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA== + +d3-ease@1: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.7.tgz#9a834890ef8b8ae8c558b2fe55bd57f5993b85e2" + integrity sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ== + +d3-format@1: + version "1.4.4" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.4.tgz#356925f28d0fd7c7983bfad593726fce46844030" + integrity sha512-TWks25e7t8/cqctxCmxpUuzZN11QxIA7YrMbram94zMQ0PXjE4LVIMe/f6a4+xxL8HQ3OsAFULOINQi1pE62Aw== + +"d3-format@1 - 2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-2.0.0.tgz#a10bcc0f986c372b729ba447382413aabf5b0767" + integrity sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA== + +d3-interpolate@1, d3-interpolate@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987" + integrity sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA== + dependencies: + d3-color "1" + +"d3-interpolate@1.2.0 - 2": + version "2.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163" + integrity sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ== + dependencies: + d3-color "1 - 2" + +d3-path@1: + version "1.0.9" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" + integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== + +d3-scale-chromatic@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz#54e333fc78212f439b14641fb55801dd81135a98" + integrity sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg== + dependencies: + d3-color "1" + d3-interpolate "1" + +d3-scale@^2.1.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-2.2.2.tgz#4e880e0b2745acaaddd3ede26a9e908a9e17b81f" + integrity sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw== + dependencies: + d3-array "^1.2.0" + d3-collection "1" + d3-format "1" + d3-interpolate "1" + d3-time "1" + d3-time-format "2" + +d3-scale@^3.2.1: + version "3.2.2" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-3.2.2.tgz#36d4cbc94dc38bbb5bd91ba5eddb44c6d19bad3e" + integrity sha512-3Mvi5HfqPFq0nlyeFlkskGjeqrR/790pINMHc4RXKJ2E6FraTd3juaRIRZZHyMAbi3LjAMW0EH4FB1WgoGyeXg== + dependencies: + d3-array "1.2.0 - 2" + d3-format "1 - 2" + d3-interpolate "1.2.0 - 2" + d3-time "1 - 2" + d3-time-format "2 - 3" + +d3-selection@1.4.2, d3-selection@^1.1.0: + version "1.4.2" + resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.4.2.tgz#dcaa49522c0dbf32d6c1858afc26b6094555bc5c" + integrity sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg== + +d3-shape@^1.2.0: + version "1.3.7" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" + integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw== + dependencies: + d3-path "1" + +d3-time-format@2: + version "2.2.3" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.2.3.tgz#0c9a12ee28342b2037e5ea1cf0b9eb4dd75f29cb" + integrity sha512-RAHNnD8+XvC4Zc4d2A56Uw0yJoM7bsvOlJR33bclxq399Rak/b9bhvu/InjxdWhPtkgU53JJcleJTGkNRnN6IA== + dependencies: + d3-time "1" + +"d3-time-format@2 - 3": + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-3.0.0.tgz#df8056c83659e01f20ac5da5fdeae7c08d5f1bb6" + integrity sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag== + dependencies: + d3-time "1 - 2" + +d3-time@1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1" + integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA== + +"d3-time@1 - 2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-2.0.0.tgz#ad7c127d17c67bd57a4c61f3eaecb81108b1e0ab" + integrity sha512-2mvhstTFcMvwStWd9Tj3e6CEqtOivtD8AUiHT8ido/xmzrI9ijrUUihZ6nHuf/vsScRBonagOdj0Vv+SEL5G3Q== + +d3-timer@1: + version "1.0.10" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.10.tgz#dfe76b8a91748831b13b6d9c793ffbd508dd9de5" + integrity sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw== + +d3-transition@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-1.3.2.tgz#a98ef2151be8d8600543434c1ca80140ae23b398" + integrity sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA== + dependencies: + d3-color "1" + d3-dispatch "1" + d3-ease "1" + d3-interpolate "1" + d3-selection "^1.1.0" + d3-timer "1" + d@1, d@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" @@ -6892,6 +7082,11 @@ decamelize@^1.1.0, decamelize@^1.1.2, decamelize@^1.2.0: resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= +decimal.js-light@^2.4.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.0.tgz#ca7faf504c799326df94b0ab920424fdfc125348" + integrity sha512-b3VJCbd2hwUpeRGG3Toob+CRo8W22xplipNhP3tN7TSVB/cyMX71P1vM2Xjc9H74uV6dS2hDDmo/rHq8L87Upg== + decimal.js@^10.2.0: version "10.2.0" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.2.0.tgz#39466113a9e036111d02f82489b5fd6b0b5ed231" @@ -7315,6 +7510,13 @@ dom-converter@^0.2: dependencies: utila "~0.4" +dom-helpers@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" + integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== + dependencies: + "@babel/runtime" "^7.1.2" + dom-helpers@^5.0.1, dom-helpers@^5.1.0: version "5.1.4" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.1.4.tgz#4609680ab5c79a45f2531441f1949b79d6587f4b" @@ -12906,6 +13108,11 @@ lodash@^4.15.0, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17. resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== +lodash@~4.17.4: + version "4.17.20" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" + integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== + log-update@^3.0.0: version "3.4.0" resolved "https://registry.yarnpkg.com/log-update/-/log-update-3.4.0.tgz#3b9a71e00ac5b1185cc193a36d654581c48f97b9" @@ -13140,6 +13347,11 @@ marked@^1.1.1: resolved "https://registry.yarnpkg.com/marked/-/marked-1.1.1.tgz#e5d61b69842210d5df57b05856e0c91572703e6a" integrity sha512-mJzT8D2yPxoPh7h0UXkB+dBj4FykPJ2OIfxAWeIHrvoHDkFxukV/29QxoFQoPM6RLEwhIFdJpmKBlqVM3s2ZIw== +math-expression-evaluator@^1.2.14: + version "1.2.22" + resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.22.tgz#c14dcb3d8b4d150e5dcea9c68c8dad80309b0d5e" + integrity sha512-L0j0tFVZBQQLeEjmWOvDLoRciIY8gQGWahvkztXUal8jH8R5Rlqo9GCvgqvXcy9LQhEWdQCVvzqAbxgYNt4blQ== + md5-file@^3.2.3: version "3.2.3" resolved "https://registry.yarnpkg.com/md5-file/-/md5-file-3.2.3.tgz#f9bceb941eca2214a4c0727f5e700314e770f06f" @@ -15762,6 +15974,13 @@ quick-lru@^4.0.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== +raf@^3.4.0: + version "3.4.1" + resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" + integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== + dependencies: + performance-now "^2.1.0" + ramda@0.21.0: version "0.21.0" resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.21.0.tgz#a001abedb3ff61077d4ff1d577d44de77e8d0a35" @@ -15822,6 +16041,15 @@ rc@^1.2.7, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-countup@^4.3.3: + version "4.3.3" + resolved "https://registry.yarnpkg.com/react-countup/-/react-countup-4.3.3.tgz#143a8d854d47290b73d6915eac20a12d839153c2" + integrity sha512-pWnxpwdPNRyJFha/YKKbyc4RLAw8PzmULdgCziGIgw6vxhT1VdccrvQgj38HBSoM2qF/MoLmn4M2klvDWVIdaw== + dependencies: + countup.js "^1.9.3" + prop-types "^15.7.2" + warning "^4.0.3" + react-data-grid@^7.0.0-canary.17: version "7.0.0-canary.18" resolved "https://registry.yarnpkg.com/react-data-grid/-/react-data-grid-7.0.0-canary.18.tgz#784067c02de81e74d17bf3bfd1c1092c3ab2fec4" @@ -16007,6 +16235,16 @@ react-refresh@^0.8.3: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg== +react-resize-detector@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/react-resize-detector/-/react-resize-detector-2.3.0.tgz#57bad1ae26a28a62a2ddb678ba6ffdf8fa2b599c" + integrity sha512-oCAddEWWeFWYH5FAcHdBYcZjAw9fMzRUK9sWSx6WvSSOPVRxcHd5zTIGy/mOus+AhN/u6T4TMiWxvq79PywnJQ== + dependencies: + lodash.debounce "^4.0.8" + lodash.throttle "^4.1.1" + prop-types "^15.6.0" + resize-observer-polyfill "^1.5.0" + react-resize-observer@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/react-resize-observer/-/react-resize-observer-1.1.1.tgz#641dfa2e0f4bd2549a8ab4bbbaf43b68f3dcaf76" @@ -16031,6 +16269,16 @@ react-side-effect@^2.1.0: resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.0.tgz#1ce4a8b4445168c487ed24dab886421f74d380d3" integrity sha512-IgmcegOSi5SNX+2Snh1vqmF0Vg/CbkycU9XZbOHJlZ6kMzTmi3yc254oB1WCkgA7OQtIAoLmcSFuHTc/tlcqXg== +react-smooth@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/react-smooth/-/react-smooth-1.0.5.tgz#94ae161d7951cdd893ccb7099d031d342cb762ad" + integrity sha512-eW057HT0lFgCKh8ilr0y2JaH2YbNcuEdFpxyg7Gf/qDKk9hqGMyXryZJ8iMGJEuKH0+wxS0ccSsBBB3W8yCn8w== + dependencies: + lodash "~4.17.4" + prop-types "^15.6.0" + raf "^3.4.0" + react-transition-group "^2.5.0" + react-switch@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/react-switch/-/react-switch-5.0.1.tgz#449277f4c3aed5286fffd0f50d5cbc2a23330406" @@ -16048,6 +16296,16 @@ react-test-renderer@^16.13.1: react-is "^16.8.6" scheduler "^0.19.1" +react-transition-group@^2.5.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d" + integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg== + dependencies: + dom-helpers "^3.4.0" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-lifecycles-compat "^3.0.4" + react-transition-group@^4.3.0, react-transition-group@^4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9" @@ -16063,6 +16321,24 @@ react-virtualized-auto-sizer@^1.0.2: resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd" integrity sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg== +react-wordcloud@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/react-wordcloud/-/react-wordcloud-1.2.7.tgz#6a21d9a64a8f9b0f7d12eef789156538794e685c" + integrity sha512-pyXvL8Iu2J258Qk2/kAwY23dIVhNpMC3dnvbXRkw5+Ert5EkJWwnwVjs9q8CmX38NWbfCKhGmpjuumBoQEtniw== + dependencies: + d3-array "^2.5.0" + d3-cloud "^1.2.5" + d3-dispatch "^1.0.6" + d3-scale "^3.2.1" + d3-scale-chromatic "^1.5.0" + d3-selection "1.4.2" + d3-transition "^1.3.2" + lodash.clonedeep "^4.5.0" + lodash.debounce "^4.0.8" + resize-observer-polyfill "^1.5.1" + seedrandom "^3.0.5" + tippy.js "^6.2.6" + react@^16.13.1, react@^16.8.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" @@ -16258,6 +16534,30 @@ realpath-native@^1.1.0: dependencies: util.promisify "^1.0.0" +recharts-scale@^0.4.2: + version "0.4.3" + resolved "https://registry.yarnpkg.com/recharts-scale/-/recharts-scale-0.4.3.tgz#040b4f638ed687a530357292ecac880578384b59" + integrity sha512-t8p5sccG9Blm7c1JQK/ak9O8o95WGhNXD7TXg/BW5bYbVlr6eCeRBNpgyigD4p6pSSMehC5nSvBUPj6F68rbFA== + dependencies: + decimal.js-light "^2.4.1" + +recharts@^1.8.5: + version "1.8.5" + resolved "https://registry.yarnpkg.com/recharts/-/recharts-1.8.5.tgz#ca94a3395550946334a802e35004ceb2583fdb12" + integrity sha512-tM9mprJbXVEBxjM7zHsIy6Cc41oO/pVYqyAsOHLxlJrbNBuLs0PHB3iys2M+RqCF0//k8nJtZF6X6swSkWY3tg== + dependencies: + classnames "^2.2.5" + core-js "^2.6.10" + d3-interpolate "^1.3.0" + d3-scale "^2.1.0" + d3-shape "^1.2.0" + lodash "^4.17.5" + prop-types "^15.6.0" + react-resize-detector "^2.3.0" + react-smooth "^1.0.5" + recharts-scale "^0.4.2" + reduce-css-calc "^1.3.0" + recursive-readdir@2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.2.1.tgz#90ef231d0778c5ce093c9a48d74e5c5422d13a99" @@ -16289,6 +16589,22 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +reduce-css-calc@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz#747c914e049614a4c9cfbba629871ad1d2927716" + integrity sha1-dHyRTgSWFKTJz7umKYca0dKSdxY= + dependencies: + balanced-match "^0.4.2" + math-expression-evaluator "^1.2.14" + reduce-function-call "^1.0.1" + +reduce-function-call@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/reduce-function-call/-/reduce-function-call-1.0.3.tgz#60350f7fb252c0a67eb10fd4694d16909971300f" + integrity sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ== + dependencies: + balanced-match "^1.0.0" + redux-batched-actions@^0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/redux-batched-actions/-/redux-batched-actions-0.5.0.tgz#d3f0e359b2a95c7d80bab442df450bfafd57d122" @@ -16677,6 +16993,11 @@ reselect@^4.0.0: resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA== +resize-observer-polyfill@^1.5.0, resize-observer-polyfill@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + resolve-cwd@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" @@ -17026,6 +17347,11 @@ section-matter@^1.0.0: extend-shallow "^2.0.1" kind-of "^6.0.0" +seedrandom@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-3.0.5.tgz#54edc85c95222525b0c7a6f6b3543d8e0b3aa0a7" + integrity sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg== + seek-bzip@^1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/seek-bzip/-/seek-bzip-1.0.6.tgz#35c4171f55a680916b52a07859ecf3b5857f21c4" @@ -18483,6 +18809,13 @@ tinycolor2@^1.4.1: resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.1.tgz#f4fad333447bc0b07d4dc8e9209d8f39a8ac77e8" integrity sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g= +tippy.js@^6.2.6: + version "6.2.6" + resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.2.6.tgz#4991bbe8f75e741fb92b5ccfeebcd072d71f8345" + integrity sha512-0tTL3WQNT0nWmpslhDryRahoBm6PT9fh1xXyDfOsvZpDzq52by2rF2nvsW0WX2j9nUZP/jSGDqfKJGjCtoGFKg== + dependencies: + "@popperjs/core" "^2.4.4" + title-case@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/title-case/-/title-case-2.1.1.tgz#3e127216da58d2bc5becf137ab91dae3a7cd8faa" @@ -20272,6 +20605,11 @@ yargs@^15.3.1: y18n "^4.0.0" yargs-parser "^18.1.2" +yarn@^1.22.10: + version "1.22.11" + resolved "https://registry.yarnpkg.com/yarn/-/yarn-1.22.11.tgz#d0104043e7349046e0e2aec977c24be106925ed6" + integrity sha512-AWje4bzqO9RUn3sdnM5N8n4ZJ0BqCc/kqFJvpOI5/EVkINXui0yuvU7NDCEF//+WaxHuNay2uOHxA4+tq1P3cg== + yauzl@^2.4.2: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"