From 7c857471ddeaeb2ec18342bf1c3b6d504765aa76 Mon Sep 17 00:00:00 2001 From: Joseph Azevedo Date: Thu, 3 Dec 2020 06:18:20 -0500 Subject: [PATCH 01/11] feat: add emoji manager from old repo Co-authored-by: Mustak Ahmed --- app/src/data/umami-analytics.ts | 52 ++++++------- app/src/store/routes/rest.ts | 35 ++++++++- app/src/store/slices/pools.ts | 9 ++- app/src/tabs/EmojiManager.tsx | 134 ++++++++++++++++++++++++++++++-- app/src/utility/types.ts | 12 +++ 5 files changed, 206 insertions(+), 36 deletions(-) diff --git a/app/src/data/umami-analytics.ts b/app/src/data/umami-analytics.ts index ef506c62..0b96caff 100644 --- a/app/src/data/umami-analytics.ts +++ b/app/src/data/umami-analytics.ts @@ -12,33 +12,33 @@ export type UmamiAnalytics = { * Gets the site's Umami analytics parameters if they exist */ export function useUmamiAnalytics(): Option { - const queryResult = useStaticQuery< - GatsbyTypes.UseUmamiAnalyticsQuery - >(graphql` - query UseUmamiAnalytics { - site { - siteMetadata { - umami { - websiteId - base - } - } - } - } - `); + // const queryResult = useStaticQuery< + // GatsbyTypes.UseUmamiAnalyticsQuery + // >(graphql` + // query UseUmamiAnalytics { + // site { + // siteMetadata { + // umami { + // websiteId + // 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, - }); - } + // 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/routes/rest.ts b/app/src/store/routes/rest.ts index 86f0cccd..1cfcdb2d 100644 --- a/app/src/store/routes/rest.ts +++ b/app/src/store/routes/rest.ts @@ -4,7 +4,7 @@ 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, Snowflake, HoarFrost } from "@app/utility/types"; export type IdentifySessionResponse = t.TypeOf; export const IdentifySessionResponse = t.interface({ @@ -103,3 +103,36 @@ 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, +}); diff --git a/app/src/store/slices/pools.ts b/app/src/store/slices/pools.ts index 4fdc89fb..294a0777 100644 --- a/app/src/store/slices/pools.ts +++ b/app/src/store/slices/pools.ts @@ -11,6 +11,7 @@ import { AutoResponse, HoarFrost, Member, + CustomEmoji, } from "@app/utility/types"; import { Option, None, Some } from "@architus/lib/option"; @@ -19,6 +20,7 @@ export type AllPoolTypes = { user: User; autoResponse: AutoResponse; member: Member; + customEmoji: CustomEmoji; }; // Runtime io-ts types @@ -27,10 +29,15 @@ export const AllPoolTypes = { user: User, autoResponse: AutoResponse, member: Member, + customEmoji: CustomEmoji, }; export const guildAgnosticPools = ["user", "guild"] as const; -export const guildSpecificPools = ["autoResponse", "member"] as const; +export const guildSpecificPools = [ + "autoResponse", + "member", + "customEmoji", +] as const; export const allPools: PoolType[] = [ ...guildAgnosticPools, ...guildSpecificPools, diff --git a/app/src/tabs/EmojiManager.tsx b/app/src/tabs/EmojiManager.tsx index f767fe58..6eac60ce 100644 --- a/app/src/tabs/EmojiManager.tsx +++ b/app/src/tabs/EmojiManager.tsx @@ -1,9 +1,15 @@ import { styled } from "linaria/react"; import React from "react"; +import { useDispatch } from "react-redux"; +import DataGrid from "@app/components/DataGrid"; import { appVerticalPadding, appHorizontalPadding } from "@app/layout"; +import { Dispatch } from "@app/store"; +import { cacheCustomEmoji, loadCustomEmoji } from "@app/store/routes"; +import { usePool } from "@app/store/slices/pools"; import { TabProps } from "@app/tabs/types"; -import Badge from "@architus/facade/components/Badge"; +import { HoarFrost, Snowflake, CustomEmoji } from "@app/utility/types"; +import Button from "@architus/facade/components/Button"; import { color } from "@architus/facade/theme/color"; const Styled = { @@ -15,14 +21,126 @@ const Styled = { font-size: 1.9rem; font-weight: 300; `, + PageOuter: styled.div` + position: relative; + display: flex; + justify-content: stretch; + align-items: stretch; + flex-direction: column; + height: 100%; + `, + Header: styled.div` + padding: 6 milli; + padding-left: 0 milli; + `, + DataGridWrapper: styled.div` + position: relative; + display: flex; + align-items: stretch; + justify-content: stretch; + flex-grow: 1; + `, }; -const EmojiManager: React.FC = () => ( - - - Emoji Manager Coming Soon - - -); +function creatBtn( + x: boolean, + dispatch: Dispatch, + emojiID: HoarFrost, + guildID: Snowflake +) { + if (x == true) { + return ( + + ); + } + return ( + + ); +} + +function loadedYN(x: boolean) { + if (x == true) { + return "Loaded"; + } + return "Cached"; +} + +const EmojiManager: React.FC = ({ guild }) => { + const columns = [ + { key: "id", name: "ID" }, + { key: "name", name: "NAME" }, + { + key: "url", + name: "IMAGE", + formatter: ({ row }: { row: CustomEmoji }) => ( + + ), + }, + { + key: "authorId", + name: "AUTHOR", + formatter: ({ row }: { row: CustomEmoji }) => ( + <>{row.authorId.getOrElse(" None ")} + ), + }, + { + key: "loaded ", + name: "LOADED", + formatter: ({ row }: { row: CustomEmoji }) => ( + <> {loadedYN(row.discordId.isDefined())} + ), + }, + { key: "numUses", name: "USES" }, + { + key: "btns", + name: "CHANGE", + formatter: ({ row }: { row: CustomEmoji }) => ( + <> + {" "} + {creatBtn(row.discordId.isDefined(), useDispatch(), row.id, guild.id)} + + ), + }, + ]; + + const { all: emojiList } = usePool({ + type: "customEmoji", + guildId: guild.id, + }); + + return ( + <> + + +

Emoji Manager

+

+ Manage the cached and loaded emojis on the server. +

+
+ + + rows={emojiList || []} + columns={columns} + rowKey="id" + /> + +
+ + ); +}; export default EmojiManager; diff --git a/app/src/utility/types.ts b/app/src/utility/types.ts index 9d13c116..c31925e5 100644 --- a/app/src/utility/types.ts +++ b/app/src/utility/types.ts @@ -618,3 +618,15 @@ export const Role = t.type({}); export type Channel = t.TypeOf; export const Channel = t.type({}); + +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)(); From 42659cc499351ad8248ac982aaa996c4f9b0ba1a Mon Sep 17 00:00:00 2001 From: Joseph Azevedo Date: Thu, 3 Dec 2020 06:51:30 -0500 Subject: [PATCH 02/11] fix: use correct option method on path prefix --- app/src/data/path-prefix.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); } From cb108c3ebd5358b5ae3717c0830b2cb3107d0974 Mon Sep 17 00:00:00 2001 From: Seth FroGo Date: Fri, 12 Mar 2021 18:52:15 -0800 Subject: [PATCH 03/11] Author Display in EmojiManager --- app/package.json | 3 +- app/src/tabs/AutoResponses/formatters.tsx | 1 + app/src/tabs/EmojiManager.tsx | 94 ++++++++++++++++++++++- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/app/package.json b/app/package.json index cbf2179c..33441e48 100644 --- a/app/package.json +++ b/app/package.json @@ -66,7 +66,8 @@ "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", diff --git a/app/src/tabs/AutoResponses/formatters.tsx b/app/src/tabs/AutoResponses/formatters.tsx index 69ad73b2..af2f38f0 100644 --- a/app/src/tabs/AutoResponses/formatters.tsx +++ b/app/src/tabs/AutoResponses/formatters.tsx @@ -265,3 +265,4 @@ export const CountFormatter: React.FC> = ({ row }) => {row.count.toLocaleString()}; + diff --git a/app/src/tabs/EmojiManager.tsx b/app/src/tabs/EmojiManager.tsx index 6eac60ce..1e71bc87 100644 --- a/app/src/tabs/EmojiManager.tsx +++ b/app/src/tabs/EmojiManager.tsx @@ -1,16 +1,20 @@ import { styled } from "linaria/react"; -import React from "react"; +import React, { useMemo } from "react"; import { useDispatch } from "react-redux"; import DataGrid from "@app/components/DataGrid"; import { appVerticalPadding, appHorizontalPadding } from "@app/layout"; import { Dispatch } from "@app/store"; import { cacheCustomEmoji, loadCustomEmoji } from "@app/store/routes"; -import { usePool } from "@app/store/slices/pools"; import { TabProps } from "@app/tabs/types"; import { HoarFrost, Snowflake, CustomEmoji } from "@app/utility/types"; import Button from "@architus/facade/components/Button"; import { color } from "@architus/facade/theme/color"; +import { AuthorData, Author } from "./AutoResponses/types"; +import { getAvatarUrl } from "@app/components/UserDisplay"; +import { usePool, usePoolEntities } from "@app/store/slices/pools"; +import UserDisplay from "@app/components/UserDisplay"; +import { gap } from "@architus/facade/theme/spacing"; const Styled = { Layout: styled.div` @@ -40,6 +44,21 @@ const Styled = { justify-content: stretch; flex-grow: 1; `, + AuthorWrapper: styled.div` + display: flex; + align-items: center; + height: 100%; + `, + Avatar: styled(UserDisplay.Avatar)` + position: relative; + display: flex; + align-items: center; + `, + Name: styled.span` + margin-left: ${gap.femto}; + color: text; + font-weight: 600; + `, }; function creatBtn( @@ -80,6 +99,38 @@ function loadedYN(x: boolean) { } const EmojiManager: React.FC = ({ guild }) => { + const { all: commands, isLoaded: hasLoaded } = usePool({ + type: "customEmoji", + guildId: guild.id, + }); + + // 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 columns = [ { key: "id", name: "ID" }, { key: "name", name: "NAME" }, @@ -94,7 +145,10 @@ const EmojiManager: React.FC = ({ guild }) => { key: "authorId", name: "AUTHOR", formatter: ({ row }: { row: CustomEmoji }) => ( - <>{row.authorId.getOrElse(" None ")} + + + {foldAuthorData(row, authorsMap).author} + ), }, { @@ -144,3 +198,37 @@ const EmojiManager: React.FC = ({ guild }) => { }; 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", + }; +} + + From 426ff5cfb7eee4b937df4e6f6e8764606c70edbb Mon Sep 17 00:00:00 2001 From: Jonathan Buchanan Date: Mon, 13 Sep 2021 02:37:29 -0400 Subject: [PATCH 04/11] add some styles --- .github/workflows/app-staging.yml | 2 +- .github/workflows/docs-staging.yml | 2 +- app/package.json | 5 + app/src/api.ts | 2 +- app/src/components/CustomEmoji.tsx | 102 ++++ app/src/components/Mention.tsx | 113 ++++ app/src/components/Timeline.tsx | 170 ++++++ app/src/data/umami-analytics.ts | 31 +- app/src/store/api/rest/types.ts | 2 +- app/src/store/routes/rest.ts | 39 +- app/src/store/slices/index.ts | 2 + app/src/store/slices/pools.ts | 30 +- app/src/store/slices/statistics.ts | 58 ++ app/src/tabs/AutoResponses/AutoResponses.tsx | 9 +- app/src/tabs/AutoResponses/types.ts | 4 +- .../tabs/{ => EmojiManager}/EmojiManager.tsx | 128 ++-- app/src/tabs/EmojiManager/GridHeader.tsx | 157 +++++ app/src/tabs/EmojiManager/index.ts | 2 + app/src/tabs/Statistics.tsx | 28 - app/src/tabs/Statistics/ChannelGraph.tsx | 71 +++ .../tabs/Statistics/CustomRechartsTooltip.tsx | 50 ++ .../Statistics/CustomResponsiveContainer.tsx | 23 + app/src/tabs/Statistics/GrowthChart.tsx | 157 +++++ app/src/tabs/Statistics/IntegrityAlert.tsx | 68 +++ app/src/tabs/Statistics/MemberGraph.tsx | 70 +++ app/src/tabs/Statistics/MentionsChart.tsx | 59 ++ .../tabs/Statistics/PersonalMessagesChart.tsx | 169 ++++++ app/src/tabs/Statistics/Statistics.tsx | 57 ++ .../tabs/Statistics/StatisticsDashboard.tsx | 570 ++++++++++++++++++ app/src/tabs/Statistics/TimeAreaChart.tsx | 142 +++++ app/src/tabs/Statistics/WordCloud.tsx | 48 ++ app/src/tabs/Statistics/index.ts | 2 + app/src/tabs/definitions.ts | 2 +- app/src/utility/discord.ts | 6 + app/src/utility/types.ts | 16 +- architus.code-workspace | 3 + design/src/theme/motion.ts | 8 + .../features/auto-responses/eval_demo.png | Bin 0 -> 74193 bytes docs/content/features/auto-responses/index.md | 107 ++++ docs/gatsby-node.ts | 1 + docs/netlify.toml | 1 + docs/src/components/TableOfContents.tsx | 18 +- docs/src/data/umami-analytics.ts | 3 + docs/src/templates/Docs/frontmatter.ts | 5 + docs/src/templates/Docs/index.tsx | 4 +- lib/src/utility/date.ts | 34 ++ lib/src/utility/index.ts | 1 + lib/src/utility/number.ts | 9 + package.json | 5 +- yarn.lock | 342 ++++++++++- 50 files changed, 2830 insertions(+), 107 deletions(-) create mode 100644 app/src/components/CustomEmoji.tsx create mode 100644 app/src/components/Mention.tsx create mode 100644 app/src/components/Timeline.tsx create mode 100644 app/src/store/slices/statistics.ts rename app/src/tabs/{ => EmojiManager}/EmojiManager.tsx (69%) create mode 100644 app/src/tabs/EmojiManager/GridHeader.tsx create mode 100644 app/src/tabs/EmojiManager/index.ts delete mode 100644 app/src/tabs/Statistics.tsx create mode 100644 app/src/tabs/Statistics/ChannelGraph.tsx create mode 100644 app/src/tabs/Statistics/CustomRechartsTooltip.tsx create mode 100644 app/src/tabs/Statistics/CustomResponsiveContainer.tsx create mode 100644 app/src/tabs/Statistics/GrowthChart.tsx create mode 100644 app/src/tabs/Statistics/IntegrityAlert.tsx create mode 100644 app/src/tabs/Statistics/MemberGraph.tsx create mode 100644 app/src/tabs/Statistics/MentionsChart.tsx create mode 100644 app/src/tabs/Statistics/PersonalMessagesChart.tsx create mode 100644 app/src/tabs/Statistics/Statistics.tsx create mode 100644 app/src/tabs/Statistics/StatisticsDashboard.tsx create mode 100644 app/src/tabs/Statistics/TimeAreaChart.tsx create mode 100644 app/src/tabs/Statistics/WordCloud.tsx create mode 100644 app/src/tabs/Statistics/index.ts create mode 100644 docs/content/features/auto-responses/eval_demo.png create mode 100644 lib/src/utility/number.ts 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 33441e48..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,9 +58,12 @@ "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", @@ -86,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..fb363156 --- /dev/null +++ b/app/src/components/CustomEmoji.tsx @@ -0,0 +1,102 @@ +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"; + +const Styled = { + TooltipName: styled.strong` + display: block; + `, + TooltipElevated: styled.div` + opacity: 0.7; + font-size: 80%; + `, + OuterContainer: styled.div` + display: flex; + flex-direction: column; + `, + ImageContainer: styled.div` + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; + `, + + Image: styled.img` + max-width: 100%; + max-height: 100%; + object-fit: contain; + `, + Mention: styled.div` + max-width: max-content; + color: ${OtherColors.Discord}; + background-color: ${transparentize(0.85, OtherColors.Discord)}; + + &:hover { + color: white; + background-color: ${transparentize(0.25, OtherColors.Discord)}; + } + `, +}; + +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) { + 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/umami-analytics.ts b/app/src/data/umami-analytics.ts index 0b96caff..b44fdbfe 100644 --- a/app/src/data/umami-analytics.ts +++ b/app/src/data/umami-analytics.ts @@ -12,20 +12,23 @@ export type UmamiAnalytics = { * Gets the site's Umami analytics parameters if they exist */ export function useUmamiAnalytics(): Option { - // const queryResult = useStaticQuery< - // GatsbyTypes.UseUmamiAnalyticsQuery - // >(graphql` - // query UseUmamiAnalytics { - // site { - // siteMetadata { - // umami { - // websiteId - // base - // } - // } - // } - // } - // `); + // 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` + query UseUmamiAnalytics { + site { + siteMetadata { + umami { + websiteId + base + } + } + } + } + `); // const umami = queryResult.site?.siteMetadata?.umami; // if ( 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 1cfcdb2d..d8c68686 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, Snowflake, HoarFrost } 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({ @@ -136,3 +144,32 @@ export const deleteCustomEmoji = makeRoute()({ 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/slices/index.ts b/app/src/store/slices/index.ts index 0d9ca088..6263c484 100644 --- a/app/src/store/slices/index.ts +++ b/app/src/store/slices/index.ts @@ -7,6 +7,7 @@ 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"; const rootReducer = combineReducers({ session: Session, @@ -16,6 +17,7 @@ const rootReducer = combineReducers({ interpret: Interpret, gateway: Gateway, pools: Pools, + statistics: Statistics, }); export default rootReducer; diff --git a/app/src/store/slices/pools.ts b/app/src/store/slices/pools.ts index 294a0777..bc79d3f4 100644 --- a/app/src/store/slices/pools.ts +++ b/app/src/store/slices/pools.ts @@ -11,6 +11,7 @@ import { AutoResponse, HoarFrost, Member, + Channel, CustomEmoji, } from "@app/utility/types"; import { Option, None, Some } from "@architus/lib/option"; @@ -20,6 +21,7 @@ export type AllPoolTypes = { user: User; autoResponse: AutoResponse; member: Member; + channel: Channel; customEmoji: CustomEmoji; }; @@ -29,6 +31,7 @@ export const AllPoolTypes = { user: User, autoResponse: AutoResponse, member: Member, + channel: Channel, customEmoji: CustomEmoji, }; @@ -36,6 +39,7 @@ export const guildAgnosticPools = ["user", "guild"] as const; export const guildSpecificPools = [ "autoResponse", "member", + "channel", "customEmoji", ] as const; export const allPools: PoolType[] = [ @@ -802,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/AutoResponses/types.ts b/app/src/tabs/AutoResponses/types.ts index a7116a1b..6b3943e7 100644 --- a/app/src/tabs/AutoResponses/types.ts +++ b/app/src/tabs/AutoResponses/types.ts @@ -1,6 +1,6 @@ -import { AutoResponse, Member } from "@app/utility/types"; +import { AutoResponse, User } from "@app/utility/types"; -export type Author = Member; +export type Author = User; export type TransformedAutoResponse = AutoResponse & { authorData: AuthorData; diff --git a/app/src/tabs/EmojiManager.tsx b/app/src/tabs/EmojiManager/EmojiManager.tsx similarity index 69% rename from app/src/tabs/EmojiManager.tsx rename to app/src/tabs/EmojiManager/EmojiManager.tsx index 1e71bc87..e9752c1b 100644 --- a/app/src/tabs/EmojiManager.tsx +++ b/app/src/tabs/EmojiManager/EmojiManager.tsx @@ -5,16 +5,19 @@ import { useDispatch } from "react-redux"; import DataGrid from "@app/components/DataGrid"; import { appVerticalPadding, appHorizontalPadding } from "@app/layout"; import { Dispatch } from "@app/store"; +import { FaDownload, FaCheckCircle, FaUpload, FaTrash } from "react-icons/fa"; +import AutoLink from "@architus/facade/components/AutoLink"; import { cacheCustomEmoji, loadCustomEmoji } from "@app/store/routes"; import { TabProps } from "@app/tabs/types"; import { HoarFrost, Snowflake, CustomEmoji } from "@app/utility/types"; -import Button from "@architus/facade/components/Button"; import { color } from "@architus/facade/theme/color"; -import { AuthorData, Author } from "./AutoResponses/types"; +import { AuthorData, Author } from "../AutoResponses/types"; import { getAvatarUrl } from "@app/components/UserDisplay"; import { usePool, usePoolEntities } from "@app/store/slices/pools"; import UserDisplay from "@app/components/UserDisplay"; import { gap } from "@architus/facade/theme/spacing"; +import { up } from "@architus/facade/theme/media"; +import GridHeader from "./GridHeader"; const Styled = { Layout: styled.div` @@ -24,6 +27,7 @@ const Styled = { color: ${color("textStrong")}; font-size: 1.9rem; font-weight: 300; + margin-bottom: ${gap.nano}; `, PageOuter: styled.div` position: relative; @@ -32,33 +36,59 @@ const Styled = { align-items: stretch; flex-direction: column; height: 100%; + + padding-top: ${gap.milli}; `, Header: styled.div` - padding: 6 milli; - padding-left: 0 milli; + padding: 0 ${gap.milli}; + + p { + margin-bottom: ${gap.micro}; + } `, DataGridWrapper: styled.div` position: relative; + flex-grow: 1; display: flex; - align-items: stretch; justify-content: stretch; - flex-grow: 1; + 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` + AuthorWrapper: styled.div` display: flex; align-items: center; height: 100%; `, - Avatar: styled(UserDisplay.Avatar)` + Avatar: styled(UserDisplay.Avatar)` position: relative; display: flex; align-items: center; `, - Name: styled.span` + Name: styled.span` margin-left: ${gap.femto}; color: text; font-weight: 600; `, + ButtonWrapper: styled.div` + max-height: 50%; + margin: 3px 0; + font-size: 2em; + `, + IconWrapper: styled.div` + display: flex; + height: 100%; + align-items: center; + justify-content: center; + color: ${color("success")}; + font-size: 1.5em; + `, }; function creatBtn( @@ -69,33 +99,33 @@ function creatBtn( ) { if (x == true) { return ( - + /> + ); } return ( - + + + dispatch(loadCustomEmoji({ routeData: { guildID, emojiID } })) + } + /> + ); } function loadedYN(x: boolean) { if (x == true) { - return "Loaded"; + return } - return "Cached"; + return <>; } const EmojiManager: React.FC = ({ guild }) => { @@ -132,15 +162,32 @@ const EmojiManager: React.FC = ({ guild }) => { }, [authorEntries]); const columns = [ - { key: "id", name: "ID" }, - { key: "name", name: "NAME" }, + { + key: "loaded ", + name: "LOADED", + width: 10, + formatter: ({ row }: { row: CustomEmoji }) => ( + <> {loadedYN(row.discordId.isDefined())} + ), + }, { key: "url", name: "IMAGE", formatter: ({ row }: { row: CustomEmoji }) => ( - + + ), + }, + { + key: "download", + name: "DOWNLOAD", + formatter: ({ row }: { row: CustomEmoji }) => ( + <> + {row.name} + ), }, + { key: "numUses", name: "USES" }, + { key: "authorId", name: "AUTHOR", @@ -152,20 +199,23 @@ const EmojiManager: React.FC = ({ guild }) => { ), }, { - key: "loaded ", - name: "LOADED", + key: "btns", + name: "MANAGE", formatter: ({ row }: { row: CustomEmoji }) => ( - <> {loadedYN(row.discordId.isDefined())} + <> + {" "} + {creatBtn(row.discordId.isDefined(), useDispatch(), row.id, guild.id)} + ), }, - { key: "numUses", name: "USES" }, { - key: "btns", - name: "CHANGE", + key: "delete", + name: "DELETE", formatter: ({ row }: { row: CustomEmoji }) => ( <> - {" "} - {creatBtn(row.discordId.isDefined(), useDispatch(), row.id, guild.id)} + + + ), }, @@ -186,7 +236,15 @@ const EmojiManager: React.FC = ({ guild }) => {

+ {}} + filterSelfAuthored={false} + onChangeFilterSelfAuthored={(newShow: boolean) => {}} + addNewRowEnable={true} + /> + rowHeight={65} rows={emojiList || []} columns={columns} rowKey="id" diff --git a/app/src/tabs/EmojiManager/GridHeader.tsx b/app/src/tabs/EmojiManager/GridHeader.tsx new file mode 100644 index 00000000..a6c13a42 --- /dev/null +++ b/app/src/tabs/EmojiManager/GridHeader.tsx @@ -0,0 +1,157 @@ +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 ViewMode = keyof typeof viewModes; +const viewModeOrder: ViewMode[] = ["Sparse", "Comfy", "Compact"]; +export const viewModes = { + Compact: { icon: Compact, label: "Compact", height: 28 }, + Comfy: { icon: Comfy, label: "Comfy", height: 36 }, + Sparse: { icon: Sparse, label: "Sparse", height: 44 }, +} as const; + +export type GridHeaderProps = { + viewMode: ViewMode; + setViewMode: (newMode: ViewMode) => void; + 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 = ({ + viewMode, + setViewMode, + filterSelfAuthored, + onChangeFilterSelfAuthored, + className, + style, +}) => ( + + + + Filter by self-authored + + + + } + /> + + {viewModeOrder.map((key) => { + const Icon = viewModes[key].icon; + return ( + + setViewMode(key as ViewMode)} + data-active={viewMode === key ? "true" : undefined} + > + + + + ); + })} + + +); + +export default GridHeader; 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..2d8b7d11 --- /dev/null +++ b/app/src/tabs/Statistics/StatisticsDashboard.tsx @@ -0,0 +1,570 @@ +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 { 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..69116e89 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", 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 c31925e5..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,7 +628,10 @@ 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, 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/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/docs/content/features/auto-responses/eval_demo.png b/docs/content/features/auto-responses/eval_demo.png new file mode 100644 index 0000000000000000000000000000000000000000..d86b6b7a65987c3e0e19a32a42cbb69598a60ff3 GIT binary patch literal 74193 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 +## 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) => (
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" From 7feba08fc77e7ac7e354f6e06f1b40f9474f96ef Mon Sep 17 00:00:00 2001 From: johnyburd Date: Sat, 25 Sep 2021 17:28:16 -0400 Subject: [PATCH 05/11] joseph help --- app/src/tabs/AutoResponses/types.ts | 4 +- app/src/tabs/EmojiManager/EmojiManager.tsx | 151 +++++++++++++----- app/src/tabs/EmojiManager/GridHeader.tsx | 8 +- .../tabs/EmojiManager/ManagerJumbotron.tsx | 79 +++++++++ .../tabs/Statistics/StatisticsDashboard.tsx | 2 + app/src/tabs/definitions.ts | 2 +- design/src/components/Button.tsx | 5 + 7 files changed, 200 insertions(+), 51 deletions(-) create mode 100644 app/src/tabs/EmojiManager/ManagerJumbotron.tsx diff --git a/app/src/tabs/AutoResponses/types.ts b/app/src/tabs/AutoResponses/types.ts index 6b3943e7..a7116a1b 100644 --- a/app/src/tabs/AutoResponses/types.ts +++ b/app/src/tabs/AutoResponses/types.ts @@ -1,6 +1,6 @@ -import { AutoResponse, User } from "@app/utility/types"; +import { AutoResponse, Member } from "@app/utility/types"; -export type Author = User; +export type Author = Member; export type TransformedAutoResponse = AutoResponse & { authorData: AuthorData; diff --git a/app/src/tabs/EmojiManager/EmojiManager.tsx b/app/src/tabs/EmojiManager/EmojiManager.tsx index e9752c1b..1cc9131b 100644 --- a/app/src/tabs/EmojiManager/EmojiManager.tsx +++ b/app/src/tabs/EmojiManager/EmojiManager.tsx @@ -1,5 +1,5 @@ import { styled } from "linaria/react"; -import React, { useMemo } from "react"; +import React, { useMemo, useState } from "react"; import { useDispatch } from "react-redux"; import DataGrid from "@app/components/DataGrid"; @@ -9,7 +9,7 @@ import { FaDownload, FaCheckCircle, FaUpload, FaTrash } from "react-icons/fa"; import AutoLink from "@architus/facade/components/AutoLink"; import { cacheCustomEmoji, loadCustomEmoji } from "@app/store/routes"; import { TabProps } from "@app/tabs/types"; -import { HoarFrost, Snowflake, CustomEmoji } from "@app/utility/types"; +import { HoarFrost, Snowflake, CustomEmoji, User } from "@app/utility/types"; import { color } from "@architus/facade/theme/color"; import { AuthorData, Author } from "../AutoResponses/types"; import { getAvatarUrl } from "@app/components/UserDisplay"; @@ -17,7 +17,15 @@ import { usePool, usePoolEntities } from "@app/store/slices/pools"; import UserDisplay from "@app/components/UserDisplay"; import { gap } from "@architus/facade/theme/spacing"; import { up } from "@architus/facade/theme/media"; -import GridHeader from "./GridHeader"; +import GridHeader, { ViewMode } from "./GridHeader"; +import ManagerJumbotron from "./ManagerJumbotron"; +import { Option } from "@architus/lib/option"; +import { useCurrentUser } from "@app/store/slices/session"; +import { isDefined } from "@architus/lib/utility"; +import Button from "@architus/facade/components/Button"; +import { viewModes } from "./GridHeader"; +import PageTitle from "@app/components/PageTitle"; +import { boolean } from "fp-ts"; const Styled = { Layout: styled.div` @@ -41,6 +49,12 @@ const Styled = { `, 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}; @@ -77,9 +91,13 @@ const Styled = { font-weight: 600; `, ButtonWrapper: styled.div` - max-height: 50%; - margin: 3px 0; - font-size: 2em; + display: flex; + align-content: center; + height: 100%; + p { + font-size: 1.5em; + font-style: bold; + } `, IconWrapper: styled.div` display: flex; @@ -93,6 +111,7 @@ const Styled = { function creatBtn( x: boolean, + author: boolean, dispatch: Dispatch, emojiID: HoarFrost, guildID: Snowflake @@ -100,40 +119,59 @@ function creatBtn( if (x == true) { return ( - - dispatch(cacheCustomEmoji({ routeData: { guildID, emojiID } })) - } - /> - + + + ); } return ( - - dispatch(loadCustomEmoji({ routeData: { guildID, emojiID } })) - } - /> + + ); } function loadedYN(x: boolean) { if (x == true) { - return + 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(); @@ -161,6 +199,8 @@ const EmojiManager: React.FC = ({ guild }) => { return authors; }, [authorEntries]); + let mayManageEmojis = !!(guild.permissions & 1073741824); + const columns = [ { key: "loaded ", @@ -173,10 +213,24 @@ const EmojiManager: React.FC = ({ guild }) => { { key: "url", name: "IMAGE", + width: 100, formatter: ({ row }: { row: CustomEmoji }) => ( ), }, + { + key: "numUses", + name: "USES", + width: 100, + formatter: ({ row }: { row: CustomEmoji }) => ( + <> + +

{row.numUses}

+ +
+ + ), + }, { key: "download", name: "DOWNLOAD", @@ -186,38 +240,46 @@ const EmojiManager: React.FC = ({ guild }) => { ), }, - { key: "numUses", name: "USES" }, - { key: "authorId", name: "AUTHOR", formatter: ({ row }: { row: CustomEmoji }) => ( - - - {foldAuthorData(row, authorsMap).author} - + + + {foldAuthorData(row, authorsMap).author} + ), }, { key: "btns", name: "MANAGE", + width: 100, formatter: ({ row }: { row: CustomEmoji }) => ( <> - {" "} - {creatBtn(row.discordId.isDefined(), useDispatch(), row.id, guild.id)} + {creatBtn(row.discordId.isDefined(), isAuthor(currentUser, row), useDispatch(), row.id, guild.id)} ), }, { key: "delete", name: "DELETE", - formatter: ({ row }: { row: CustomEmoji }) => ( - <> - - - - - ), + width: 100, + formatter: ({ row }: { row: CustomEmoji }) => { + + return ( + <> + + + + + + + ) + }, }, ]; @@ -226,25 +288,26 @@ const EmojiManager: React.FC = ({ guild }) => { guildId: guild.id, }); + const [viewMode, setViewMode] = useState("Comfy" as ViewMode); return ( <> +

Emoji Manager

-

- Manage the cached and loaded emojis on the server. -

+ {}} + viewMode={viewMode} + setViewMode={setViewMode} filterSelfAuthored={false} - onChangeFilterSelfAuthored={(newShow: boolean) => {}} - addNewRowEnable={true} + onChangeFilterSelfAuthored={(newShow: boolean) => { }} + addNewRowEnable={false} + onAddNewRow={() => { }} /> - rowHeight={65} + rowHeight={viewModes[viewMode].height} rows={emojiList || []} columns={columns} rowKey="id" @@ -265,7 +328,7 @@ export default EmojiManager; * @param customEmoji - Current row auto response object * @param authors - Map of IDs to User objects to use for fast lookup */ - function foldAuthorData( +function foldAuthorData( customEmoji: CustomEmoji, authors: Map ): AuthorData { diff --git a/app/src/tabs/EmojiManager/GridHeader.tsx b/app/src/tabs/EmojiManager/GridHeader.tsx index a6c13a42..c7a715b7 100644 --- a/app/src/tabs/EmojiManager/GridHeader.tsx +++ b/app/src/tabs/EmojiManager/GridHeader.tsx @@ -86,9 +86,9 @@ const Styled = { export type ViewMode = keyof typeof viewModes; const viewModeOrder: ViewMode[] = ["Sparse", "Comfy", "Compact"]; export const viewModes = { - Compact: { icon: Compact, label: "Compact", height: 28 }, - Comfy: { icon: Comfy, label: "Comfy", height: 36 }, - Sparse: { icon: Sparse, label: "Sparse", height: 44 }, + Compact: { icon: Compact, label: "Compact", height: 44 }, + Comfy: { icon: Comfy, label: "Comfy", height: 52 }, + Sparse: { icon: Sparse, label: "Sparse", height: 60 }, } as const; export type GridHeaderProps = { @@ -125,7 +125,7 @@ const GridHeader: React.FC = ({ } diff --git a/app/src/tabs/EmojiManager/ManagerJumbotron.tsx b/app/src/tabs/EmojiManager/ManagerJumbotron.tsx new file mode 100644 index 00000000..a1205021 --- /dev/null +++ b/app/src/tabs/EmojiManager/ManagerJumbotron.tsx @@ -0,0 +1,79 @@ +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 } 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 Styled = { + Outer: styled.div` + background-color: ${color('bg-10')}; + border-radius: ${gap.pico}; + margin: ${gap.milli} 0; + display: flex; + justify-content: space-evenly; + + ${up("md")} { + margin: ${gap.milli} ${gap.milli}; + border-top-left-radius: 1rem; + } + `, + Help: styled.div` + display: flex; + flex-direction: column; + padding: ${gap.nano}; + `, + Counter: styled.div` + border-radius: 50%; + width: 50px; + height: 50px; + background-color: ${color("bg-20")}; + `, + Control: styled.div` + display: flex; + flex-direction: column; + padding: ${gap.nano}; + `, + +}; + + +export type GridHeaderProps = { + +}; + +/** + * Big banner thing at the top of the emoji manager page for extra info and controls. + */ +const ManagerJumbotron: React.FC = ({ + +}) => ( + + +

What's this?

+ +
+ + 6/50 + + +

Enable Emoji Manager

+ {}} + checked={true} + /> + +
+
+); + +export default ManagerJumbotron; diff --git a/app/src/tabs/Statistics/StatisticsDashboard.tsx b/app/src/tabs/Statistics/StatisticsDashboard.tsx index 2d8b7d11..8a284e29 100644 --- a/app/src/tabs/Statistics/StatisticsDashboard.tsx +++ b/app/src/tabs/Statistics/StatisticsDashboard.tsx @@ -44,6 +44,7 @@ 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"; +import PageTitle from "@app/components/PageTitle"; const Styled = { PageOuter: styled.div` @@ -435,6 +436,7 @@ const StatisticsDashboard: React.FC = ({ return ( + Statistics Date: Sat, 25 Sep 2021 19:11:55 -0400 Subject: [PATCH 06/11] button work --- app/src/tabs/EmojiManager/EmojiManager.tsx | 108 ++++++++++----------- app/src/tabs/EmojiManager/GridHeader.tsx | 32 ------ design/src/components/Button.tsx | 10 ++ 3 files changed, 61 insertions(+), 89 deletions(-) diff --git a/app/src/tabs/EmojiManager/EmojiManager.tsx b/app/src/tabs/EmojiManager/EmojiManager.tsx index 1cc9131b..3abc2c95 100644 --- a/app/src/tabs/EmojiManager/EmojiManager.tsx +++ b/app/src/tabs/EmojiManager/EmojiManager.tsx @@ -10,7 +10,7 @@ import AutoLink from "@architus/facade/components/AutoLink"; import { cacheCustomEmoji, loadCustomEmoji } from "@app/store/routes"; import { TabProps } from "@app/tabs/types"; import { HoarFrost, Snowflake, CustomEmoji, User } from "@app/utility/types"; -import { color } from "@architus/facade/theme/color"; +import { Color, color, hybridColor } from "@architus/facade/theme/color"; import { AuthorData, Author } from "../AutoResponses/types"; import { getAvatarUrl } from "@app/components/UserDisplay"; import { usePool, usePoolEntities } from "@app/store/slices/pools"; @@ -26,6 +26,7 @@ import Button from "@architus/facade/components/Button"; import { viewModes } from "./GridHeader"; import PageTitle from "@app/components/PageTitle"; import { boolean } from "fp-ts"; +import { useColorMode } from "@architus/facade/hooks"; const Styled = { Layout: styled.div` @@ -84,7 +85,8 @@ const Styled = { position: relative; display: flex; align-items: center; - `, + flex-shrink: 0; + `, Name: styled.span` margin-left: ${gap.femto}; color: text; @@ -92,11 +94,20 @@ const Styled = { `, ButtonWrapper: styled.div` display: flex; - align-content: center; + align-items: center; + height: 100%; + & > :not(:last-child) { + margin-right: ${gap('femto')}; + } + `, + UsesWrapper: styled.div` + display: flex; + justify-content: right; height: 100%; p { font-size: 1.5em; font-style: bold; + text-align: right; } `, IconWrapper: styled.div` @@ -105,7 +116,8 @@ const Styled = { align-items: center; justify-content: center; color: ${color("success")}; - font-size: 1.5em; + font-size: 1.2em; + padding: 4px 0; `, }; @@ -116,36 +128,33 @@ function creatBtn( emojiID: HoarFrost, guildID: Snowflake ) { + const colorMode = useColorMode(); if (x == true) { return ( - - - - - ); - } - return ( - - - + + ); + } + return ( + ); } @@ -200,6 +209,7 @@ const EmojiManager: React.FC = ({ guild }) => { }, [authorEntries]); let mayManageEmojis = !!(guild.permissions & 1073741824); + const colorMode = useColorMode(); const columns = [ { @@ -224,10 +234,10 @@ const EmojiManager: React.FC = ({ guild }) => { width: 100, formatter: ({ row }: { row: CustomEmoji }) => ( <> - -

{row.numUses}

+ +

{row.numUses}

-
+ ), }, @@ -253,34 +263,21 @@ const EmojiManager: React.FC = ({ guild }) => { { key: "btns", name: "MANAGE", - width: 100, + width: 150, formatter: ({ row }: { row: CustomEmoji }) => ( <> - {creatBtn(row.discordId.isDefined(), isAuthor(currentUser, row), useDispatch(), row.id, guild.id)} + + {creatBtn(row.discordId.isDefined(), isAuthor(currentUser, row), useDispatch(), row.id, guild.id)} + + + ), }, - { - key: "delete", - name: "DELETE", - width: 100, - formatter: ({ row }: { row: CustomEmoji }) => { - - return ( - <> - - - - - - - ) - }, - }, ]; const { all: emojiList } = usePool({ @@ -288,7 +285,6 @@ const EmojiManager: React.FC = ({ guild }) => { guildId: guild.id, }); - const [viewMode, setViewMode] = useState("Comfy" as ViewMode); return ( <> @@ -296,18 +292,16 @@ const EmojiManager: React.FC = ({ guild }) => {

Emoji Manager

- + { }} addNewRowEnable={false} onAddNewRow={() => { }} /> - rowHeight={viewModes[viewMode].height} + rowHeight={52} rows={emojiList || []} columns={columns} rowKey="id" diff --git a/app/src/tabs/EmojiManager/GridHeader.tsx b/app/src/tabs/EmojiManager/GridHeader.tsx index c7a715b7..fe7232f9 100644 --- a/app/src/tabs/EmojiManager/GridHeader.tsx +++ b/app/src/tabs/EmojiManager/GridHeader.tsx @@ -83,17 +83,8 @@ const Styled = { `, }; -export type ViewMode = keyof typeof viewModes; -const viewModeOrder: ViewMode[] = ["Sparse", "Comfy", "Compact"]; -export const viewModes = { - Compact: { icon: Compact, label: "Compact", height: 44 }, - Comfy: { icon: Comfy, label: "Comfy", height: 52 }, - Sparse: { icon: Sparse, label: "Sparse", height: 60 }, -} as const; export type GridHeaderProps = { - viewMode: ViewMode; - setViewMode: (newMode: ViewMode) => void; filterSelfAuthored: boolean; onChangeFilterSelfAuthored: (newShow: boolean) => void; addNewRowEnable: boolean; @@ -107,8 +98,6 @@ export type GridHeaderProps = { * providing a set of options */ const GridHeader: React.FC = ({ - viewMode, - setViewMode, filterSelfAuthored, onChangeFilterSelfAuthored, className, @@ -130,27 +119,6 @@ const GridHeader: React.FC = ({ } /> - - {viewModeOrder.map((key) => { - const Icon = viewModes[key].icon; - return ( - - setViewMode(key as ViewMode)} - data-active={viewMode === key ? "true" : undefined} - > - - - - ); - })} - ); diff --git a/design/src/components/Button.tsx b/design/src/components/Button.tsx index 7dbf303d..d9bbba54 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; From 0b4b4f1915a75f57d9499efc3854bf05f151fa69 Mon Sep 17 00:00:00 2001 From: Joseph Azevedo Date: Sat, 25 Sep 2021 19:42:10 -0400 Subject: [PATCH 07/11] add ur dumb css --- app/src/tabs/EmojiManager/EmojiManager.tsx | 63 ++++--- .../tabs/EmojiManager/ManagerJumbotron.tsx | 162 +++++++++++++----- 2 files changed, 153 insertions(+), 72 deletions(-) diff --git a/app/src/tabs/EmojiManager/EmojiManager.tsx b/app/src/tabs/EmojiManager/EmojiManager.tsx index 1cc9131b..761395d1 100644 --- a/app/src/tabs/EmojiManager/EmojiManager.tsx +++ b/app/src/tabs/EmojiManager/EmojiManager.tsx @@ -84,7 +84,7 @@ const Styled = { position: relative; display: flex; align-items: center; - `, + `, Name: styled.span` margin-left: ${gap.femto}; color: text; @@ -122,7 +122,7 @@ function creatBtn( - ); } return ( @@ -138,7 +137,7 @@ function creatBtn( - ); } function loadedYN(x: boolean) { if (x == true) { - return + return ( + + + + ); } return <>; } @@ -224,10 +226,9 @@ const EmojiManager: React.FC = ({ guild }) => { width: 100, formatter: ({ row }: { row: CustomEmoji }) => ( <> - -

{row.numUses}

- -
+ +

{row.numUses}

+
), }, @@ -236,7 +237,9 @@ const EmojiManager: React.FC = ({ guild }) => { name: "DOWNLOAD", formatter: ({ row }: { row: CustomEmoji }) => ( <> - {row.name} + + {row.name} + ), }, @@ -245,7 +248,11 @@ const EmojiManager: React.FC = ({ guild }) => { name: "AUTHOR", formatter: ({ row }: { row: CustomEmoji }) => ( - + {foldAuthorData(row, authorsMap).author} ), @@ -256,7 +263,13 @@ const EmojiManager: React.FC = ({ guild }) => { width: 100, formatter: ({ row }: { row: CustomEmoji }) => ( <> - {creatBtn(row.discordId.isDefined(), isAuthor(currentUser, row), useDispatch(), row.id, guild.id)} + {creatBtn( + row.discordId.isDefined(), + isAuthor(currentUser, row), + useDispatch(), + row.id, + guild.id + )} ), }, @@ -265,20 +278,17 @@ const EmojiManager: React.FC = ({ guild }) => { name: "DELETE", width: 100, formatter: ({ row }: { row: CustomEmoji }) => { - return ( <> - - - - ) + ); }, }, ]; @@ -296,15 +306,22 @@ const EmojiManager: React.FC = ({ guild }) => {

Emoji Manager

- + undefined} + /> { }} + onChangeFilterSelfAuthored={(newShow: boolean) => {}} addNewRowEnable={false} - onAddNewRow={() => { }} + onAddNewRow={() => {}} /> rowHeight={viewModes[viewMode].height} @@ -320,8 +337,6 @@ const EmojiManager: React.FC = ({ guild }) => { export default EmojiManager; - - /** * Performs the row transformation operation, resolving auto responses to the necessary * fields for display @@ -351,5 +366,3 @@ function foldAuthorData( avatarUrl: "/img/unknown.png", }; } - - diff --git a/app/src/tabs/EmojiManager/ManagerJumbotron.tsx b/app/src/tabs/EmojiManager/ManagerJumbotron.tsx index a1205021..6b1a7f06 100644 --- a/app/src/tabs/EmojiManager/ManagerJumbotron.tsx +++ b/app/src/tabs/EmojiManager/ManagerJumbotron.tsx @@ -4,76 +4,144 @@ 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 { up } from "@architus/facade/theme/media"; +import { down, up } from "@architus/facade/theme/media"; import { shadow } from "@architus/facade/theme/shadow"; import { gap } from "@architus/facade/theme/spacing"; +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` - background-color: ${color('bg-10')}; border-radius: ${gap.pico}; - margin: ${gap.milli} 0; + margin: 0 0 ${gap.micro}; + overflow: hidden; display: flex; - justify-content: space-evenly; - + flex-direction: row; + align-items: stretch; + ${up("md")} { - margin: ${gap.milli} ${gap.milli}; - border-top-left-radius: 1rem; + margin-left: ${gap.milli}; + margin-right: ${gap.milli}; + } + + ${down("md")} { + border-radius: 0; } `, - Help: styled.div` - display: flex; - flex-direction: column; - padding: ${gap.nano}; - `, - Counter: styled.div` - border-radius: 50%; - width: 50px; - height: 50px; - background-color: ${color("bg-20")}; + Left: styled.div` + background-color: ${color("bg-10")}; + display: grid; + --padding: ${gap.micro}; + padding: var(--padding) 0; `, - Control: styled.div` - display: flex; - flex-direction: column; - padding: ${gap.nano}; + 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: 700px; + width: 100%; + display: grid; + grid-auto-flow: row; + grid-template-columns: 3fr 3fr 5fr 5fr 5fr; + grid-template-rows: auto; + column-gap: 1px; -export type GridHeaderProps = { + ${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; + discordLimit: number; + architusLimit: number | "infinity"; }; /** * Big banner thing at the top of the emoji manager page for extra info and controls. */ -const ManagerJumbotron: React.FC = ({ - -}) => ( - - -

What's this?

- -
- - 6/50 - - -

Enable Emoji Manager

- {}} - checked={true} - /> - -
-
-); - -export default ManagerJumbotron; +export default function ManagerJumbotron({ + docsLink, + enabled, + onChangeEnabled, + current, + discordLimit, + architusLimit, +}: ManagerJumbotronProps): React.ReactElement { + return ( + + + + + Enable + + + + Current + {current} + + + Discord Limit + {discordLimit} + + + Architus Limit + {architusLimit} + + + More Info + docs.archit.us + + + + + + ); +} From 2aa99cdfb7099dd13df8ea947eb09a5940b6f866 Mon Sep 17 00:00:00 2001 From: johnyburd Date: Sat, 25 Sep 2021 23:54:36 -0400 Subject: [PATCH 08/11] filter self authored --- app/src/tabs/EmojiManager/EmojiManager.tsx | 153 ++++++++++++------ .../tabs/EmojiManager/ManagerJumbotron.tsx | 2 +- 2 files changed, 106 insertions(+), 49 deletions(-) diff --git a/app/src/tabs/EmojiManager/EmojiManager.tsx b/app/src/tabs/EmojiManager/EmojiManager.tsx index 26e8afec..3786b3f4 100644 --- a/app/src/tabs/EmojiManager/EmojiManager.tsx +++ b/app/src/tabs/EmojiManager/EmojiManager.tsx @@ -1,6 +1,6 @@ import { boolean } from "fp-ts"; import { styled } from "linaria/react"; -import React, { useMemo, useState } from "react"; +import React, { MutableRefObject, useMemo, useState } from "react"; import { FaDownload, FaCheckCircle, FaUpload, FaTrash } from "react-icons/fa"; import { useDispatch } from "react-redux"; @@ -25,6 +25,8 @@ import { up } from "@architus/facade/theme/media"; import { gap } from "@architus/facade/theme/spacing"; import { Option } from "@architus/lib/option"; import { isDefined } from "@architus/lib/utility"; +import { Column } from "react-data-grid"; +import { padding } from "polished"; const Styled = { Layout: styled.div` @@ -36,6 +38,18 @@ const Styled = { 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; @@ -102,6 +116,7 @@ const Styled = { display: flex; justify-content: right; height: 100%; + padding-right: ${gap('femto')}; p { font-size: 1.5em; font-style: bold; @@ -119,6 +134,29 @@ const Styled = { `, }; +function centerHeader(item) { + return ( +
+ {item.column.name} +
+ ); +} + +function rightHeader(item: MutableRefObject) { + return ( +
+ {item.column.name} +
+ ); +} + function creatBtn( x: boolean, author: boolean, @@ -133,7 +171,7 @@ function creatBtn( type="solid" size="compact" disabled={!author && x} - color={hybridColor("bg", colorMode)} + color={hybridColor("bg+10", colorMode)} > = ({ guild }) => { const mayManageEmojis = !!(guild.permissions & 1073741824); const colorMode = useColorMode(); - const columns = [ + const columns: Column[] = [ { key: "loaded ", name: "LOADED", width: 10, + headerRenderer: centerHeader, formatter: ({ row }: { row: CustomEmoji }) => ( <> {loadedYN(row.discordId.isDefined())} ), @@ -234,25 +273,17 @@ const EmojiManager: React.FC = ({ guild }) => { key: "url", name: "IMAGE", width: 100, + headerRenderer: centerHeader, formatter: ({ row }: { row: CustomEmoji }) => ( - - ), - }, - { - key: "numUses", - name: "USES", - width: 100, - formatter: ({ row }: { row: CustomEmoji }) => ( - <> - -

{row.numUses}

-
- + + + ), }, { key: "download", name: "DOWNLOAD", + sortable: true, formatter: ({ row }: { row: CustomEmoji }) => ( <> @@ -264,6 +295,7 @@ const EmojiManager: React.FC = ({ guild }) => { { key: "authorId", name: "AUTHOR", + sortable: true, formatter: ({ row }: { row: CustomEmoji }) => ( = ({ guild }) => { ), }, { - key: "btns", - name: "MANAGE", - width: 150, + key: "numUses", + name: "USES", + sortable: true, + width: 10, + headerRenderer: rightHeader, formatter: ({ row }: { row: CustomEmoji }) => ( <> - - {creatBtn( - row.discordId.isDefined(), - isAuthor(currentUser, row), - useDispatch(), - row.id, - guild.id - )} - - - + +

{row.numUses}

+
), }, ]; + if (mayManageEmojis) { + columns.push( + { + key: "btns", + name: "MANAGE", + width: 150, + formatter: ({ row }: { row: CustomEmoji }) => ( + <> + + {creatBtn( + row.discordId.isDefined(), + isAuthor(currentUser, row), + useDispatch(), + 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]); + return ( <> @@ -320,23 +377,23 @@ const EmojiManager: React.FC = ({ guild }) => { undefined} /> {}} + filterSelfAuthored={filterSelfAuthored} + onChangeFilterSelfAuthored={setFilterSelfAuthored} addNewRowEnable={false} - onAddNewRow={() => {}} + onAddNewRow={() => { }} /> rowHeight={52} - rows={emojiList || []} - columns={columns} + rows={filteredList || []} + columns={columns as readonly Column[]} rowKey="id" /> diff --git a/app/src/tabs/EmojiManager/ManagerJumbotron.tsx b/app/src/tabs/EmojiManager/ManagerJumbotron.tsx index 6b1a7f06..07287509 100644 --- a/app/src/tabs/EmojiManager/ManagerJumbotron.tsx +++ b/app/src/tabs/EmojiManager/ManagerJumbotron.tsx @@ -101,7 +101,7 @@ export type ManagerJumbotronProps = { onChangeEnabled: (next: boolean) => boolean; current: number; discordLimit: number; - architusLimit: number | "infinity"; + architusLimit: number | "unlimited"; }; /** From 9bfacd1418d494185ad05219ba654840c655535e Mon Sep 17 00:00:00 2001 From: johnyburd Date: Mon, 27 Sep 2021 01:49:20 -0400 Subject: [PATCH 09/11] extremely scuffed saga --- app/src/store/actions.ts | 1 + app/src/store/api/rest/middleware.ts | 5 + app/src/store/routes/rest.ts | 12 +- app/src/store/saga/index.ts | 30 +++++ app/src/store/slices/emojiManager.ts | 38 +++++++ app/src/store/slices/index.ts | 2 + app/src/tabs/EmojiManager/EmojiManager.tsx | 123 ++++++++++++++++----- app/src/tabs/EmojiManager/ManageButton.tsx | 72 ++++++++++++ 8 files changed, 251 insertions(+), 32 deletions(-) create mode 100644 app/src/store/slices/emojiManager.ts create mode 100644 app/src/tabs/EmojiManager/ManageButton.tsx 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/routes/rest.ts b/app/src/store/routes/rest.ts index d8c68686..667551d7 100644 --- a/app/src/store/routes/rest.ts +++ b/app/src/store/routes/rest.ts @@ -117,8 +117,8 @@ export const guilds = makeRoute()({ */ export const loadCustomEmoji = makeRoute()({ label: "custEmoji/load", - route: ({ guildID, emojiID }: { guildID: Snowflake; emojiID: HoarFrost }) => - `/emojis/${guildID}/${emojiID}`, + route: ({ guildId, emojiId }: { guildId: Snowflake; emojiId: HoarFrost }) => + `/emojis/${guildId}/${emojiId}`, method: HttpVerbs.POST, auth: true, }); @@ -128,8 +128,8 @@ export const loadCustomEmoji = makeRoute()({ */ export const cacheCustomEmoji = makeRoute()({ label: "custEmoji/cache", - route: ({ guildID, emojiID }: { guildID: Snowflake; emojiID: HoarFrost }) => - `/emojis/${guildID}/${emojiID}`, + route: ({ guildId, emojiId }: { guildId: Snowflake; emojiId: HoarFrost }) => + `/emojis/${guildId}/${emojiId}`, method: HttpVerbs.PATCH, auth: true, }); @@ -139,8 +139,8 @@ export const cacheCustomEmoji = makeRoute()({ */ export const deleteCustomEmoji = makeRoute()({ label: "custEmoji/delete", - route: ({ guildID, emojiID }: { guildID: Snowflake; emojiID: HoarFrost }) => - `/emojis/${guildID}/${emojiID}`, + route: ({ guildId, emojiId }: { guildId: Snowflake; emojiId: HoarFrost }) => + `/emojis/${guildId}/${emojiId}`, method: HttpVerbs.DELETE, auth: true, }); 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 6263c484..46a8fcbe 100644 --- a/app/src/store/slices/index.ts +++ b/app/src/store/slices/index.ts @@ -8,6 +8,7 @@ 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, @@ -18,6 +19,7 @@ const rootReducer = combineReducers({ gateway: Gateway, pools: Pools, statistics: Statistics, +// emojiManager: EmojiManager, }); export default rootReducer; diff --git a/app/src/tabs/EmojiManager/EmojiManager.tsx b/app/src/tabs/EmojiManager/EmojiManager.tsx index 3786b3f4..e140e3ee 100644 --- a/app/src/tabs/EmojiManager/EmojiManager.tsx +++ b/app/src/tabs/EmojiManager/EmojiManager.tsx @@ -1,6 +1,6 @@ import { boolean } from "fp-ts"; import { styled } from "linaria/react"; -import React, { MutableRefObject, useMemo, useState } from "react"; +import React, { MutableRefObject, useEffect, useMemo, useState } from "react"; import { FaDownload, FaCheckCircle, FaUpload, FaTrash } from "react-icons/fa"; import { useDispatch } from "react-redux"; @@ -23,10 +23,11 @@ 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 { Option } from "@architus/lib/option"; +import { None, Option, Some } from "@architus/lib/option"; import { isDefined } from "@architus/lib/utility"; -import { Column } from "react-data-grid"; +import { Column, SortDirection } from "react-data-grid"; import { padding } from "polished"; +import { API_BASE } from "@app/api"; const Styled = { Layout: styled.div` @@ -124,14 +125,14 @@ 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; - `, + display: flex; + height: 100%; + align-items: center; + justify-content: center; + color: ${color("success")}; + font-size: 1.2em; + padding: 4px 0; +`, }; function centerHeader(item) { @@ -161,8 +162,8 @@ function creatBtn( x: boolean, author: boolean, dispatch: Dispatch, - emojiID: HoarFrost, - guildID: Snowflake + emojiId: HoarFrost, + guildId: Snowflake ) { const colorMode = useColorMode(); if (x == true) { @@ -172,13 +173,16 @@ function creatBtn( size="compact" disabled={!author && x} color={hybridColor("bg+10", colorMode)} + onClick={() => { + console.log("dispatch cache") + dispatch(cacheCustomEmoji({ routeData: { guildId, emojiId } })) + } + } > - dispatch(cacheCustomEmoji({ routeData: { guildID, emojiID } })) - } + /> @@ -190,13 +194,16 @@ function creatBtn( size="compact" disabled={!author && x} color={hybridColor("bg+10", colorMode)} + onClick={() => { + console.log("dispatch load") + dispatch(loadCustomEmoji({ routeData: { guildId, emojiId } })) + } + } > - dispatch(loadCustomEmoji({ routeData: { guildID, emojiID } })) - } + /> @@ -261,9 +268,10 @@ const EmojiManager: React.FC = ({ guild }) => { const columns: Column[] = [ { - key: "loaded ", + key: "loaded", name: "LOADED", width: 10, + sortable: true, headerRenderer: centerHeader, formatter: ({ row }: { row: CustomEmoji }) => ( <> {loadedYN(row.discordId.isDefined())} @@ -322,6 +330,7 @@ const EmojiManager: React.FC = ({ guild }) => { ), }, ]; + const dispatch = useDispatch(); if (mayManageEmojis) { columns.push( { @@ -334,7 +343,7 @@ const EmojiManager: React.FC = ({ guild }) => { {creatBtn( row.discordId.isDefined(), isAuthor(currentUser, row), - useDispatch(), + dispatch, row.id, guild.id )} @@ -368,6 +377,65 @@ const EmojiManager: React.FC = ({ guild }) => { }); }, [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) + console.log("HELOLO") + } + console.log(sortedRows); + return sort.get.direction === "DESC" ? sortedRows.reverse() : sortedRows; + } + return commands; + }, [filteredList, sort]); + + + const onSort = (column: string, direction: SortDirection): void => { + console.log(column); + console.log(direction); + 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 ( <> @@ -376,11 +444,11 @@ const EmojiManager: React.FC = ({ guild }) => {

Emoji Manager

undefined} /> @@ -392,7 +460,10 @@ const EmojiManager: React.FC = ({ guild }) => { /> rowHeight={52} - rows={filteredList || []} + rows={getSortedRows || []} + sortColumn={sort.getOrElse(undefined)?.column} + sortDirection={sort.getOrElse(undefined)?.direction} + onSort={onSort} columns={columns as readonly Column[]} rowKey="id" /> 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; From 82a40f4e5e2ab5609f3b08d21acaf97b2cc2fd89 Mon Sep 17 00:00:00 2001 From: johnyburd Date: Sun, 3 Oct 2021 18:33:05 -0400 Subject: [PATCH 10/11] neat usage bar & lots of tooltips --- app/src/components/CustomEmoji.tsx | 52 ++++--- app/src/tabs/EmojiManager/EmojiChart.tsx | 65 ++++++++ app/src/tabs/EmojiManager/EmojiChart2.tsx | 147 ++++++++++++++++++ app/src/tabs/EmojiManager/EmojiManager.tsx | 68 +++++--- .../tabs/EmojiManager/ManagerJumbotron.tsx | 18 ++- 5 files changed, 301 insertions(+), 49 deletions(-) create mode 100644 app/src/tabs/EmojiManager/EmojiChart.tsx create mode 100644 app/src/tabs/EmojiManager/EmojiChart2.tsx diff --git a/app/src/components/CustomEmoji.tsx b/app/src/components/CustomEmoji.tsx index fb363156..2b1639d1 100644 --- a/app/src/components/CustomEmoji.tsx +++ b/app/src/components/CustomEmoji.tsx @@ -11,6 +11,7 @@ import { formatDateExtraShort, formatNum, } from "@architus/lib/utility"; +import { gap } from "@architus/facade/theme/spacing"; const Styled = { TooltipName: styled.strong` @@ -21,30 +22,36 @@ const Styled = { 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.pico} + 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%; - `, - Image: styled.img` - max-width: 100%; - max-height: 100%; - object-fit: contain; + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } `, - Mention: styled.div` - max-width: max-content; - color: ${OtherColors.Discord}; - background-color: ${transparentize(0.85, OtherColors.Discord)}; + PreviewContainer: styled.div` - &:hover { - color: white; - background-color: ${transparentize(0.25, OtherColors.Discord)}; - } `, }; @@ -65,7 +72,7 @@ export const CustomEmojiIcon: React.FC = ({ className, }) => { let authorName = null; - if (isDefined(author) && author.id === emoji.authorId) { + if (isDefined(author) && author.id === emoji.authorId.getOrElse("0" as Snowflake)) { authorName = ( {author.name}#{author.discriminator} @@ -85,17 +92,20 @@ export const CustomEmojiIcon: React.FC = ({ maxWidth={"auto"} tooltip={ - :{emoji.name}: - {authorName} - {date} - - uses: {formatNum(emoji.numUses)} - + + + :{emoji.name}: + {authorName} + {date} + + uses: {formatNum(emoji.numUses)} + + } > - + ); 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..79d2a883 --- /dev/null +++ b/app/src/tabs/EmojiManager/EmojiChart2.tsx @@ -0,0 +1,147 @@ +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: ${gap.milli}; + } + + ${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

+ + ); +}; + +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 }
+ return null +} + +export type EmojiChartProps = { + current: number, + loaded: number, + limit: number, + 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 +}) => { + const data = [ + { + name: 'Page A', + loaded: loaded, + cached: current - loaded, + free: limit - current + 10 + }, + + ]; + + return ( + + + + + tick === discordLimit ? "Discord Limit (50)" : "Architus Limit (200)"} + scale="sqrt" + /> + + + + } 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 index e140e3ee..4107f68f 100644 --- a/app/src/tabs/EmojiManager/EmojiManager.tsx +++ b/app/src/tabs/EmojiManager/EmojiManager.tsx @@ -28,6 +28,9 @@ 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"; const Styled = { Layout: styled.div` @@ -161,6 +164,7 @@ function rightHeader(item: MutableRefObject) { function creatBtn( x: boolean, author: boolean, + admin: boolean, dispatch: Dispatch, emojiId: HoarFrost, guildId: Snowflake @@ -168,10 +172,11 @@ function creatBtn( const colorMode = useColorMode(); if (x == true) { return ( + ); } return ( + + + + + + ); } @@ -283,9 +295,17 @@ const EmojiManager: React.FC = ({ guild }) => { width: 100, headerRenderer: centerHeader, formatter: ({ row }: { row: CustomEmoji }) => ( - +/* + */ + + + ), }, { @@ -343,6 +363,7 @@ const EmojiManager: React.FC = ({ guild }) => { {creatBtn( row.discordId.isDefined(), isAuthor(currentUser, row), + guild.architus_admin, dispatch, row.id, guild.id @@ -406,9 +427,7 @@ const EmojiManager: React.FC = ({ guild }) => { break; default: sortedRows = sortedRows.sort((a, b) => a.priority - b.priority) - console.log("HELOLO") } - console.log(sortedRows); return sort.get.direction === "DESC" ? sortedRows.reverse() : sortedRows; } return commands; @@ -416,8 +435,6 @@ const EmojiManager: React.FC = ({ guild }) => { const onSort = (column: string, direction: SortDirection): void => { - console.log(column); - console.log(direction); setSort( direction !== "NONE" ? Some({ column: column as ColumnKey, direction }) @@ -446,11 +463,18 @@ const EmojiManager: React.FC = ({ guild }) => { e.discordId.isDefined()).length} discordLimit={managerConf.discord_limit} architusLimit={managerConf.architus_limit} docsLink="https://docs.archit.us/features/emoji-manager/" onChangeEnabled={(): void => undefined} /> + e.discordId.isDefined()).length} + limit={200} + discordLimit={managerConf.discord_limit} + /> boolean; current: number; + loaded: number; discordLimit: number; architusLimit: number | "unlimited"; }; @@ -112,6 +114,7 @@ export default function ManagerJumbotron({ enabled, onChangeEnabled, current, + loaded, discordLimit, architusLimit, }: ManagerJumbotronProps): React.ReactElement { @@ -124,12 +127,15 @@ export default function ManagerJumbotron({ - Current + Total {current} + {/* */} - Discord Limit - {discordLimit} + Loaded +
+ {loaded} / {discordLimit} +
Architus Limit From 80003d2e7314abd459a4e3c2a34b11eaa12ad6a0 Mon Sep 17 00:00:00 2001 From: johnyburd Date: Sat, 9 Oct 2021 15:55:41 -0400 Subject: [PATCH 11/11] dialog component and stuff --- app/src/components/CustomEmoji.tsx | 2 +- app/src/tabs/EmojiManager/EmojiChart2.tsx | 35 +-- app/src/tabs/EmojiManager/EmojiManager.tsx | 16 +- app/src/tabs/EmojiManager/EmojiTooltip.tsx | 84 +++++++ .../tabs/EmojiManager/ManagerJumbotron.tsx | 1 - design/src/components/Button.tsx | 5 +- design/src/components/Dialog.tsx | 225 ++++++++++++++++++ design/src/theme/order.ts | 1 + 8 files changed, 348 insertions(+), 21 deletions(-) create mode 100644 app/src/tabs/EmojiManager/EmojiTooltip.tsx create mode 100644 design/src/components/Dialog.tsx diff --git a/app/src/components/CustomEmoji.tsx b/app/src/components/CustomEmoji.tsx index 2b1639d1..7b54c09e 100644 --- a/app/src/components/CustomEmoji.tsx +++ b/app/src/components/CustomEmoji.tsx @@ -29,7 +29,7 @@ const Styled = { object-fit: cover; width: 100%; height: 90px; - margin-right: ${gap.pico} + margin-right: ${gap.nano}; max-width: 300px; } `, diff --git a/app/src/tabs/EmojiManager/EmojiChart2.tsx b/app/src/tabs/EmojiManager/EmojiChart2.tsx index 79d2a883..a617a4cd 100644 --- a/app/src/tabs/EmojiManager/EmojiChart2.tsx +++ b/app/src/tabs/EmojiManager/EmojiChart2.tsx @@ -19,17 +19,17 @@ import { transition } from "@architus/facade/theme/motion"; const Styled = { Outer: styled.div` - width: 100%; + //width: 100%; //height: 100px; //display: flex; justify-content: center; align-items: center; //margin-bottom: ${gap.nano}; margin-top: -5px; - padding-right: 58px; + //padding-right: 58px; ${up("md")} { margin-left: calc(${gap.milli} - 5px); - margin-right: ${gap.milli}; + margin-right: calc(${gap.milli} - 5px); } ${down("md")} { @@ -78,19 +78,12 @@ const tooltipRenderer = ( ); }; -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 }
- return null -} + export type EmojiChartProps = { current: number, loaded: number, - limit: number, + limit: number | 'unlimited', discordLimit: number, }; @@ -104,12 +97,22 @@ const COLORS = ['#5850ba', '#844ea3', '#ba5095', '#ffbfa7']; 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: limit - current + 10 + free: architusLimit - current }, ]; @@ -127,12 +130,12 @@ const EmojiChart: React.FC = React.memo(({ axisLine={false} type="number" - ticks={[discordLimit, limit]} + ticks={[discordLimit, architusLimit]} //tickFormatter={(tick) => tick === discordLimit ? "Discord Limit (50)" : "Architus Limit (200)"} - scale="sqrt" + scale="linear" /> - + } isAnimationActive={false}/> tooltip="loaded" }/> diff --git a/app/src/tabs/EmojiManager/EmojiManager.tsx b/app/src/tabs/EmojiManager/EmojiManager.tsx index 4107f68f..77859c3a 100644 --- a/app/src/tabs/EmojiManager/EmojiManager.tsx +++ b/app/src/tabs/EmojiManager/EmojiManager.tsx @@ -31,6 +31,8 @@ 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` @@ -275,6 +277,8 @@ const EmojiManager: React.FC = ({ guild }) => { return authors; }, [authorEntries]); + const [show, setShow] = useState(false); + const mayManageEmojis = !!(guild.permissions & 1073741824); const colorMode = useColorMode(); @@ -373,6 +377,7 @@ const EmojiManager: React.FC = ({ guild }) => { disabled={!isAuthor(currentUser, row)} size="compact" type="solid" + onClick={() => setShow(true)} color={hybridColor("bg+10", colorMode)} > @@ -455,6 +460,11 @@ const EmojiManager: React.FC = ({ guild }) => { return ( <> + setShow(false)} + header="Delete this emoji?" + /> @@ -465,14 +475,16 @@ const EmojiManager: React.FC = ({ guild }) => { current={emojiList.length} loaded={emojiList.filter((e) => e.discordId.isDefined()).length} discordLimit={managerConf.discord_limit} - architusLimit={managerConf.architus_limit} + architusLimit={250} docsLink="https://docs.archit.us/features/emoji-manager/" onChangeEnabled={(): void => undefined} /> e.discordId.isDefined()).length} - limit={200} + //limit={250} + //discordLimit={managerConf.discord_limit} + limit={250} discordLimit={managerConf.discord_limit} /> 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/ManagerJumbotron.tsx b/app/src/tabs/EmojiManager/ManagerJumbotron.tsx index 66e64fba..b8be3b98 100644 --- a/app/src/tabs/EmojiManager/ManagerJumbotron.tsx +++ b/app/src/tabs/EmojiManager/ManagerJumbotron.tsx @@ -129,7 +129,6 @@ export default function ManagerJumbotron({ Total {current} - {/* */} Loaded diff --git a/design/src/components/Button.tsx b/design/src/components/Button.tsx index d9bbba54..51dac992 100644 --- a/design/src/components/Button.tsx +++ b/design/src/components/Button.tsx @@ -150,6 +150,9 @@ const typeClasses = { } as const; const sizeClasses = { + compacter: css` + --button-padding: ${gap.femto}; + `, compact: css` --button-padding: ${gap.pico}; `, @@ -161,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/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,
  • {item.title} - {isDefined(item.items) ? ( + {isDefined(item.items) && currentDepth < maxDepth ? (
      - +
    ) : null}