diff --git a/apps/api-gql/internal/gql/resolvers/emotes-statistic.resolver.go b/apps/api-gql/internal/gql/resolvers/emotes-statistic.resolver.go new file mode 100644 index 000000000..7262fc7a5 --- /dev/null +++ b/apps/api-gql/internal/gql/resolvers/emotes-statistic.resolver.go @@ -0,0 +1,223 @@ +package resolvers + +// This file will be automatically regenerated based on the schema, any resolver implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.45 + +import ( + "context" + "fmt" + + model "github.com/satont/twir/libs/gomodels" + data_loader "github.com/twirapp/twir/apps/api-gql/internal/gql/data-loader" + "github.com/twirapp/twir/apps/api-gql/internal/gql/gqlmodel" + "github.com/twirapp/twir/apps/api-gql/internal/gql/graph" +) + +// TwitchProfile is the resolver for the twitchProfile field. +func (r *emoteStatisticUserUsageResolver) TwitchProfile( + ctx context.Context, + obj *gqlmodel.EmoteStatisticUserUsage, +) (*gqlmodel.TwirUserTwitchInfo, error) { + return data_loader.GetHelixUserById(ctx, obj.UserID) +} + +// EmotesStatistics is the resolver for the emotesStatistics field. +func (r *queryResolver) EmotesStatistics( + ctx context.Context, + opts gqlmodel.EmotesStatisticsOpts, +) (*gqlmodel.EmotesStatisticResponse, error) { + dashboardId, err := r.sessions.GetSelectedDashboard(ctx) + if err != nil { + return nil, err + } + + var page int + perPage := 10 + + if opts.Page.IsSet() { + page = *opts.Page.Value() + } + + if opts.PerPage.IsSet() { + perPage = *opts.PerPage.Value() + } + + query := r.gorm.WithContext(ctx). + Where(`"channelId" = ?`, dashboardId). + Limit(perPage). + Offset(page * perPage) + + if opts.Search.IsSet() && *opts.Search.Value() != "" { + query = query.Where(`"emote" LIKE ?`, "%"+*opts.Search.Value()+"%") + } + + var order gqlmodel.EmotesStatisticsOptsOrder + if opts.Order.IsSet() { + order = *opts.Order.Value() + } else { + order = gqlmodel.EmotesStatisticsOptsOrderDesc + } + + var entities []emoteEntityModelWithCount + if err := + query. + Select(`"emote", COUNT(emote) as count`). + Group("emote"). + Order(fmt.Sprintf("count %s", order.String())). + Find(&entities). + Error; err != nil { + return nil, err + } + + var totalCount int64 + if err := r.gorm. + WithContext(ctx). + Raw( + ` + SELECT COUNT(DISTINCT emote) + FROM channels_emotes_usages + WHERE "channelId" = ? + `, + dashboardId, + ). + Scan(&totalCount).Error; err != nil { + return nil, fmt.Errorf("failed to get total count: %w", err) + } + + models := make([]gqlmodel.EmotesStatistic, 0, len(entities)) + for _, entity := range entities { + lastUsedEntity := &model.ChannelEmoteUsage{} + if err := r.gorm. + WithContext(ctx). + Where(`"channelId" = ? AND "emote" = ?`, dashboardId, entity.Emote). + Order(`"createdAt" DESC`). + First(lastUsedEntity).Error; err != nil { + return nil, err + } + + var rangeType gqlmodel.EmoteStatisticRange + if opts.GraphicRange.IsSet() { + rangeType = *opts.GraphicRange.Value() + } else { + rangeType = gqlmodel.EmoteStatisticRangeLastDay + } + + graphicUsages, err := r.getEmoteStatisticUsagesForRange( + ctx, + entity.Emote, + rangeType, + ) + if err != nil { + return nil, err + } + + models = append( + models, gqlmodel.EmotesStatistic{ + EmoteName: entity.Emote, + TotalUsages: entity.Count, + LastUsedTimestamp: int(lastUsedEntity.CreatedAt.UTC().UnixMilli()), + GraphicUsages: graphicUsages, + }, + ) + } + + return &gqlmodel.EmotesStatisticResponse{ + Emotes: models, + Total: int(totalCount), + }, nil +} + +// EmotesStatisticEmoteDetailedInformation is the resolver for the emotesStatisticEmoteDetailedInformation field. +func (r *queryResolver) EmotesStatisticEmoteDetailedInformation( + ctx context.Context, + opts gqlmodel.EmotesStatisticEmoteDetailedOpts, +) (*gqlmodel.EmotesStatisticEmoteDetailedResponse, error) { + if opts.EmoteName == "" { + return nil, nil + } + + dashboardId, err := r.sessions.GetSelectedDashboard(ctx) + if err != nil { + return nil, err + } + + graphicUsages, err := r.getEmoteStatisticUsagesForRange(ctx, opts.EmoteName, opts.Range) + if err != nil { + return nil, err + } + + lastUsedEntity := &model.ChannelEmoteUsage{} + if err := r.gorm. + WithContext(ctx). + Where(`"channelId" = ? AND "emote" = ?`, dashboardId, opts.EmoteName). + Order(`"createdAt" DESC`). + First(lastUsedEntity).Error; err != nil { + return nil, err + } + + var usages int64 + if err := r.gorm. + WithContext(ctx). + Model(&model.ChannelEmoteUsage{}). + Where(`"channelId" = ? AND "emote" = ?`, dashboardId, opts.EmoteName). + Count(&usages).Error; err != nil { + return nil, err + } + + var usagedByUserPage int + usagesByUserPerPage := 10 + + if opts.UsagesByUsersPage.IsSet() { + usagedByUserPage = *opts.UsagesByUsersPage.Value() + } + + if opts.UsagesByUsersPerPage.IsSet() { + usagesByUserPerPage = *opts.UsagesByUsersPerPage.Value() + } + + var usagesByUsers []model.ChannelEmoteUsage + if err := r.gorm. + WithContext(ctx). + Where(`"channelId" = ? AND "emote" = ?`, dashboardId, opts.EmoteName). + Limit(usagesByUserPerPage). + Offset(usagedByUserPage * usagesByUserPerPage). + Find(&usagesByUsers).Error; err != nil { + return nil, err + } + + var usagesByUsersTotalCount int64 + if err := r.gorm. + WithContext(ctx). + Model(&model.ChannelEmoteUsage{}). + Where(`"channelId" = ? AND "emote" = ?`, dashboardId, opts.EmoteName). + Count(&usagesByUsersTotalCount).Error; err != nil { + return nil, err + } + + users := make([]gqlmodel.EmoteStatisticUserUsage, 0, len(usagesByUsers)) + for _, usage := range usagesByUsers { + users = append( + users, gqlmodel.EmoteStatisticUserUsage{ + UserID: usage.UserID, + Date: usage.CreatedAt, + }, + ) + } + + return &gqlmodel.EmotesStatisticEmoteDetailedResponse{ + EmoteName: opts.EmoteName, + TotalUsages: int(usages), + LastUsedTimestamp: int(lastUsedEntity.CreatedAt.UTC().UnixMilli()), + GraphicUsages: graphicUsages, + UsagesByUsers: users, + UsagesByUsersTotal: int(usagesByUsersTotalCount), + }, nil +} + +// EmoteStatisticUserUsage returns graph.EmoteStatisticUserUsageResolver implementation. +func (r *Resolver) EmoteStatisticUserUsage() graph.EmoteStatisticUserUsageResolver { + return &emoteStatisticUserUsageResolver{r} +} + +type emoteStatisticUserUsageResolver struct{ *Resolver } diff --git a/apps/api-gql/internal/gql/resolvers/emotes-statistic.resolver.service.go b/apps/api-gql/internal/gql/resolvers/emotes-statistic.resolver.service.go new file mode 100644 index 000000000..123b9757a --- /dev/null +++ b/apps/api-gql/internal/gql/resolvers/emotes-statistic.resolver.service.go @@ -0,0 +1,101 @@ +package resolvers + +import ( + "context" + "database/sql" + "fmt" + "time" + + model "github.com/satont/twir/libs/gomodels" + "github.com/twirapp/twir/apps/api-gql/internal/gql/gqlmodel" +) + +func (r *queryResolver) getEmoteStatisticUsagesForRange( + ctx context.Context, + emoteName string, + timeRange gqlmodel.EmoteStatisticRange, +) ([]gqlmodel.EmoteStatisticUsage, error) { + dashboardId, err := r.sessions.GetSelectedDashboard(ctx) + if err != nil { + return nil, err + } + + var usages []emoteStatisticUsageModel + + var interval string + var truncateBy string + switch timeRange { + case gqlmodel.EmoteStatisticRangeLastDay: + interval = "24 hours" + truncateBy = "hour" + case gqlmodel.EmoteStatisticRangeLastWeek: + interval = "7 days" + truncateBy = "day" + case gqlmodel.EmoteStatisticRangeLastMonth: + interval = "30 days" + truncateBy = "day" + case gqlmodel.EmoteStatisticRangeLastThreeMonth: + interval = "90 days" + truncateBy = "day" + case gqlmodel.EmoteStatisticRangeLastYear: + interval = "365 days" + truncateBy = "day" + default: + } + + query := fmt.Sprintf( + ` +SELECT +hh AS time, COUNT(emote) AS count +FROM (select + generate_series( + DATE_TRUNC(@truncate_by, NOW() - INTERVAL '%s'), + DATE_TRUNC(@truncate_by, NOW()), + INTERVAL '1 %s' + ) as hh +) s +left join channels_emotes_usages on DATE_TRUNC(@truncate_by, "createdAt") = hh AND "channelId" = @dashboard_id AND emote = @emote_name +GROUP BY + time +ORDER BY + time asc; + `, interval, truncateBy, + ) + + if err := r.gorm. + WithContext(ctx). + Raw( + query, + sql.Named("truncate_by", truncateBy), + sql.Named("my_interval", interval), + sql.Named("dashboard_id", dashboardId), + sql.Named("emote_name", emoteName), + ). + Find(&usages).Error; err != nil { + return nil, err + } + + result := make([]gqlmodel.EmoteStatisticUsage, 0, len(usages)) + for _, usage := range usages { + result = append( + result, + gqlmodel.EmoteStatisticUsage{ + Count: usage.Count, + Timestamp: int(usage.Time.UTC().UnixMilli()), + }, + ) + } + + return result, nil +} + +type emoteEntityModelWithCount struct { + model.ChannelEmoteUsage + Count int `gorm:"column:count"` +} + +type emoteStatisticUsageModel struct { + Emote string `gorm:"column:emote"` + Count int `gorm:"column:count"` + Time time.Time `gorm:"column:time"` +} diff --git a/apps/api-gql/schema/emotes-statistic.graphqls b/apps/api-gql/schema/emotes-statistic.graphqls new file mode 100644 index 000000000..f68ff0633 --- /dev/null +++ b/apps/api-gql/schema/emotes-statistic.graphqls @@ -0,0 +1,64 @@ +extend type Query { + emotesStatistics(opts: EmotesStatisticsOpts!): EmotesStatisticResponse! @isAuthenticated + emotesStatisticEmoteDetailedInformation(opts: EmotesStatisticEmoteDetailedOpts!): EmotesStatisticEmoteDetailedResponse! @isAuthenticated +} + +type EmotesStatisticResponse { + emotes: [EmotesStatistic!]! + total: Int! +} + +type EmotesStatistic { + emoteName: String! + totalUsages: Int! + lastUsedTimestamp: Int! + graphicUsages: [EmoteStatisticUsage!]! +} + +enum EmotesStatisticsOptsOrder { + ASC + DESC +} + +input EmotesStatisticsOpts { + search: String + page: Int + perPage: Int + graphicRange: EmoteStatisticRange + order: EmotesStatisticsOptsOrder +} + +enum EmoteStatisticRange { + LAST_DAY + LAST_WEEK + LAST_MONTH + LAST_THREE_MONTH + LAST_YEAR +} + +input EmotesStatisticEmoteDetailedOpts { + emoteName: String! + range: EmoteStatisticRange! + usagesByUsersPage: Int + usagesByUsersPerPage: Int +} + +type EmotesStatisticEmoteDetailedResponse { + emoteName: String! + totalUsages: Int! + lastUsedTimestamp: Int! + graphicUsages: [EmoteStatisticUsage!]! + usagesByUsers: [EmoteStatisticUserUsage!]! + usagesByUsersTotal: Int! +} + +type EmoteStatisticUsage { + count: Int! + timestamp: Int! +} + +type EmoteStatisticUserUsage { + userId: String! + twitchProfile: TwirUserTwitchInfo! @goField(forceResolver: true) + date: Time! +} diff --git a/eslint.config.js b/eslint.config.js index 0ea960ae0..741a6004a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,23 +15,24 @@ export default antfu({ 'style/no-tabs': 'off', 'antfu/if-newline': 'off', 'style/indent': ['error', 'tab'], - 'eslint-comments/no-unlimited-disable': 'off' + 'eslint-comments/no-unlimited-disable': 'off', + 'style/comma-dangle': ['error', 'always-multiline'], }, vue: { overrides: { 'vue/block-order': ['error', { - order: [['script', 'template'], 'style'] + order: [['script', 'template'], 'style'], }], 'vue/multi-word-component-names': [ - 'off' + 'off', ], 'vue/max-attributes-per-line': 'off', 'vue/static-class-names-order': 'off', 'vue/attribute-hyphenation': 'off', 'vue/html-self-closing': 'off', 'vue/html-indent': ['error', 'tab'], - 'vue/no-v-text-v-html-on-component': 'off' - } + 'vue/no-v-text-v-html-on-component': 'off', + }, }, stylistic: { overrides: { @@ -47,11 +48,11 @@ export default antfu({ 'error', 'single', { - allowTemplateLiterals: true - } + allowTemplateLiterals: true, + }, ], 'style/brace-style': [ - 'error' + 'error', ], 'style/comma-spacing': 'off', 'style/func-call-spacing': 'off', @@ -59,8 +60,8 @@ export default antfu({ 'error', { destructuring: 'all', - ignoreReadBeforeAssign: false - } + ignoreReadBeforeAssign: false, + }, ], 'import/order': [ 'error', @@ -69,45 +70,45 @@ export default antfu({ 'builtin', 'external', [ - 'internal' + 'internal', ], [ 'parent', - 'sibling' + 'sibling', ], 'index', - 'type' + 'type', ], 'newlines-between': 'always', 'alphabetize': { order: 'asc', - caseInsensitive: true + caseInsensitive: true, }, 'pathGroups': [ { pattern: 'src/**', group: 'internal', - position: 'after' - } - ] - } + position: 'after', + }, + ], + }, ], 'import/no-cycle': [ 2, { - maxDepth: 1 - } + maxDepth: 1, + }, ], 'import/newline-after-import': [ 'error', { - count: 1 - } + count: 1, + }, ], 'style/object-curly-spacing': [ 2, - 'always' - ] - } - } + 'always', + ], + }, + }, }) diff --git a/frontend/dashboard/package.json b/frontend/dashboard/package.json index 63410d4a2..de08bea3e 100644 --- a/frontend/dashboard/package.json +++ b/frontend/dashboard/package.json @@ -8,7 +8,7 @@ "preview": "vite preview", "analyze": "vite-bundle-visualizer", "generate-pwa-assets": "pwa-assets-generator", - "codegen": "graphql-codegen" + "codegen": "graphql-codegen" }, "dependencies": { "@discord-message-components/vue": "0.2.1", @@ -39,6 +39,7 @@ "graphql-ws": "5.16.0", "grid-layout-plus": "1.0.4", "kappagen": "0.3.5", + "lightweight-charts": "4.1.3", "lodash.chunk": "4.2.0", "lucide-vue-next": "0.368.0", "naive-ui": "2.38.1", diff --git a/frontend/dashboard/src/api/emotes-statistic.ts b/frontend/dashboard/src/api/emotes-statistic.ts new file mode 100644 index 000000000..5687f5c85 --- /dev/null +++ b/frontend/dashboard/src/api/emotes-statistic.ts @@ -0,0 +1,71 @@ +import { useQuery } from '@urql/vue' + +import type { + EmotesStatisticEmoteDetailedOpts, + EmotesStatisticQuery, + EmotesStatisticsDetailsQuery, + EmotesStatisticsOpts, +} from '@/gql/graphql' +import type { Ref } from 'vue' + +import { graphql } from '@/gql' + +export type EmotesStatistics = EmotesStatisticQuery['emotesStatistics']['emotes'] + +export function useEmotesStatisticQuery(opts: Ref) { + return useQuery({ + get variables() { + return { + opts: opts.value, + } + }, + query: graphql(` + query EmotesStatistic($opts: EmotesStatisticsOpts!) { + emotesStatistics(opts: $opts) { + emotes { + emoteName + totalUsages + lastUsedTimestamp + graphicUsages { + count + timestamp + } + } + total + } + } + `), + }) +} + +export type EmotesStatisticsDetail = EmotesStatisticsDetailsQuery + +export function useEmotesStatisticDetailsQuery(opts: Ref) { + return useQuery({ + get variables() { + return { + opts: opts.value, + } + }, + query: graphql(` + query EmotesStatisticsDetails($opts: EmotesStatisticEmoteDetailedOpts!) { + emotesStatisticEmoteDetailedInformation(opts: $opts) { + graphicUsages { + count + timestamp + } + usagesByUsers { + date + userId + twitchProfile { + login + displayName + profileImageUrl + } + } + usagesByUsersTotal + } + } + `), + }) +} diff --git a/frontend/dashboard/src/components/alerts/list.vue b/frontend/dashboard/src/components/alerts/list.vue index dde6bbd57..c094047c3 100644 --- a/frontend/dashboard/src/components/alerts/list.vue +++ b/frontend/dashboard/src/components/alerts/list.vue @@ -1,52 +1,55 @@ diff --git a/frontend/dashboard/src/components/dashboard/events.vue b/frontend/dashboard/src/components/dashboard/events.vue index 86e65a9f0..41b302d54 100644 --- a/frontend/dashboard/src/components/dashboard/events.vue +++ b/frontend/dashboard/src/components/dashboard/events.vue @@ -1,32 +1,33 @@ diff --git a/frontend/dashboard/src/features/community-emotes-statistic/components/community-emotes-table-column-actions.vue b/frontend/dashboard/src/features/community-emotes-statistic/components/community-emotes-table-column-actions.vue new file mode 100644 index 000000000..784478606 --- /dev/null +++ b/frontend/dashboard/src/features/community-emotes-statistic/components/community-emotes-table-column-actions.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/dashboard/src/features/community-emotes-statistic/components/community-emotes-table-column-chart-range.vue b/frontend/dashboard/src/features/community-emotes-statistic/components/community-emotes-table-column-chart-range.vue new file mode 100644 index 000000000..0f139fb87 --- /dev/null +++ b/frontend/dashboard/src/features/community-emotes-statistic/components/community-emotes-table-column-chart-range.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/dashboard/src/features/community-emotes-statistic/components/community-emotes-table-column-chart.vue b/frontend/dashboard/src/features/community-emotes-statistic/components/community-emotes-table-column-chart.vue new file mode 100644 index 000000000..7ca374387 --- /dev/null +++ b/frontend/dashboard/src/features/community-emotes-statistic/components/community-emotes-table-column-chart.vue @@ -0,0 +1,124 @@ + + + diff --git a/frontend/dashboard/src/features/community-emotes-statistic/components/community-emotes-table-column.vue b/frontend/dashboard/src/features/community-emotes-statistic/components/community-emotes-table-column.vue new file mode 100644 index 000000000..26ae30c16 --- /dev/null +++ b/frontend/dashboard/src/features/community-emotes-statistic/components/community-emotes-table-column.vue @@ -0,0 +1,55 @@ + + + diff --git a/frontend/dashboard/src/features/community-emotes-statistic/components/community-emotes-table.vue b/frontend/dashboard/src/features/community-emotes-statistic/components/community-emotes-table.vue new file mode 100644 index 000000000..20f780d63 --- /dev/null +++ b/frontend/dashboard/src/features/community-emotes-statistic/components/community-emotes-table.vue @@ -0,0 +1,26 @@ + + + diff --git a/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-chart-size.ts b/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-chart-size.ts new file mode 100644 index 000000000..1a0dfa6d4 --- /dev/null +++ b/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-chart-size.ts @@ -0,0 +1,18 @@ +import { ref } from 'vue' + +const chartSizes = ref({ + width: 0, + height: 0, +}) + +export function useCommunityChartSize() { + function setChartSize(width: number, height: number) { + chartSizes.value.width = width + chartSizes.value.height = height + } + + return { + chartSizes, + setChartSize, + } +} diff --git a/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-chart-styles.ts b/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-chart-styles.ts new file mode 100644 index 000000000..073d0bdf9 --- /dev/null +++ b/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-chart-styles.ts @@ -0,0 +1,26 @@ +import { defineStore } from 'pinia' +import { computed } from 'vue' + +import { useTheme } from '@/composables/use-theme.js' + +export const useCommunityChartStyles = defineStore('features/community-chart-styles', () => { + const { theme } = useTheme() + + const chartStyles = computed(() => { + const isDark = theme.value === 'dark' + const styles = getComputedStyle(document.documentElement) + + const textColor = `hsl(${styles.getPropertyValue('--foreground')})` + const borderColor = `hsl(${styles.getPropertyValue('--border')})` + + return { + isDark, + textColor, + borderColor, + } + }) + + return { + chartStyles, + } +}) diff --git a/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-emotes-details-users.ts b/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-emotes-details-users.ts new file mode 100644 index 000000000..95b8e3817 --- /dev/null +++ b/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-emotes-details-users.ts @@ -0,0 +1,95 @@ +import { + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getPaginationRowModel, + useVueTable, +} from '@tanstack/vue-table' +import { NTime } from 'naive-ui' +import { defineStore, storeToRefs } from 'pinia' +import { computed, h } from 'vue' + +import { useCommunityEmotesDetails } from './use-community-emotes-details' + +import type { EmotesStatisticsDetail } from '@/api/emotes-statistic' +import type { ColumnDef } from '@tanstack/vue-table' + +import UsersTableCellUser + from '@/features/admin-panel/manage-users/components/users-table-cell-user.vue' +import { resolveUserName } from '@/helpers' +import { valueUpdater } from '@/helpers/value-updater' + +type UserUsage = NonNullable['usagesByUsers'][number] + +export const useCommunityEmotesDetailsUsers = defineStore( + 'features/community-emotes-statistic-table/details-users', + () => { + const { details, pagination } = storeToRefs(useCommunityEmotesDetails()) + + const data = computed(() => { + return details.value?.emotesStatisticEmoteDetailedInformation?.usagesByUsers ?? [] + }) + const total = computed(() => details.value?.emotesStatisticEmoteDetailedInformation?.usagesByUsersTotal ?? 0) + const pageCount = computed(() => { + return Math.ceil(total.value / pagination.value.pageSize) + }) + + const columns = computed[]>(() => [ + { + accessorKey: 'user', + size: 50, + header: () => '', + cell: ({ row }) => { + return h('a', { + class: 'flex flex-col', + href: `https://twitch.tv/${row.original.twitchProfile.login}`, + target: '_blank', + }, h(UsersTableCellUser, { + avatar: row.original.twitchProfile.profileImageUrl, + userId: row.original.userId, + name: resolveUserName(row.original.twitchProfile.login, row.original.twitchProfile.displayName), + })) + }, + }, + { + accessorKey: 'time', + header: '', + cell: ({ row }) => { + const date = new Date(row.original.date) + const diff = Date.now() - date.getTime() + return h(NTime, { type: 'relative', time: 0, to: diff }) + }, + }, + ]) + + const table = useVueTable({ + get pageCount() { + return pageCount.value + }, + get data() { + return data.value + }, + get columns() { + return columns.value + }, + state: { + get pagination() { + return pagination.value + }, + }, + manualPagination: true, + onPaginationChange: (updaterOrValue) => valueUpdater(updaterOrValue, pagination), + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }) + + return { + table, + pagination, + total, + pageCount, + } + }, +) diff --git a/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-emotes-details.ts b/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-emotes-details.ts new file mode 100644 index 000000000..9a8775575 --- /dev/null +++ b/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-emotes-details.ts @@ -0,0 +1,51 @@ +import { defineStore, storeToRefs } from 'pinia' +import { computed, ref } from 'vue' + +import type { EmotesStatisticEmoteDetailedOpts } from '@/gql/graphql' + +import { useEmotesStatisticDetailsQuery } from '@/api/emotes-statistic' +import { usePagination } from '@/composables/use-pagination' +import { EmoteStatisticRange } from '@/gql/graphql' + +export const useCommunityEmotesDetailsName = defineStore( + 'features/community-emotes-statistic-table/details-name', + () => { + const emoteName = ref() + + function setEmoteName(name: string) { + emoteName.value = name + } + + return { + emoteName, + setEmoteName, + } + }, +) + +export const useCommunityEmotesDetails = defineStore( + 'features/community-emotes-statistic-table/details', + () => { + const { emoteName } = storeToRefs(useCommunityEmotesDetailsName()) + const { pagination } = usePagination() + const range = ref(EmoteStatisticRange.LastDay) + + const opts = computed(() => { + return { + emoteName: emoteName.value!, + range: range.value, + usagesByUsersPage: pagination.value.pageIndex, + usagesByUsersPerPage: pagination.value.pageSize, + } + }) + + const { data: details, fetching: isLoading } = useEmotesStatisticDetailsQuery(opts) + + return { + range, + details, + isLoading, + pagination, + } + }, +) diff --git a/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-emotes-statistic-filters.ts b/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-emotes-statistic-filters.ts new file mode 100644 index 000000000..01efcee5c --- /dev/null +++ b/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-emotes-statistic-filters.ts @@ -0,0 +1,41 @@ +import { refDebounced } from '@vueuse/core' +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import type { SortingState } from '@tanstack/vue-table' + +import { EmoteStatisticRange, EmotesStatisticsOptsOrder } from '@/gql/graphql.js' + +export const useCommunityEmotesStatisticFilters = defineStore('features/community-emotes-statistic-filters', () => { + const searchInput = ref('') + const debouncedSearchInput = refDebounced(searchInput, 500) + + const sortingState = ref([ + { + desc: true, + id: 'usages', // accessorKey + }, + ]) + + const tableOrder = computed(() => { + return sortingState.value[0].desc + ? EmotesStatisticsOptsOrder.Desc + : EmotesStatisticsOptsOrder.Asc + }) + + const tableRange = ref(EmoteStatisticRange.LastDay) + function changeTableRange(range: EmoteStatisticRange) { + tableRange.value = range + } + + return { + searchInput, + debouncedSearchInput, + + sortingState, + tableOrder, + + tableRange, + changeTableRange, + } +}) diff --git a/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-emotes-statistic-table.ts b/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-emotes-statistic-table.ts new file mode 100644 index 000000000..c17caf19d --- /dev/null +++ b/frontend/dashboard/src/features/community-emotes-statistic/composables/use-community-emotes-statistic-table.ts @@ -0,0 +1,139 @@ +import { + type ColumnDef, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getPaginationRowModel, + getSortedRowModel, + useVueTable, +} from '@tanstack/vue-table' +import { defineStore, storeToRefs } from 'pinia' +import { computed, h } from 'vue' +import { useI18n } from 'vue-i18n' + +import { useCommunityEmotesStatisticFilters } from './use-community-emotes-statistic-filters.js' +import CommunityEmotesTableColumnChartRange + from '../components/community-emotes-table-column-chart-range.vue' +import CommunityEmotesTableColumnChart from '../components/community-emotes-table-column-chart.vue' +import CommunityEmotesTableColumn from '../components/community-emotes-table-column.vue' + +import { type EmotesStatistics, useEmotesStatisticQuery } from '@/api/emotes-statistic.js' +import { usePagination } from '@/composables/use-pagination.js' +import CommunityEmotesTableColumnActions + from '@/features/community-emotes-statistic/components/community-emotes-table-column-actions.vue' +import { EmoteStatisticRange, type EmotesStatisticsOpts } from '@/gql/graphql' +import { valueUpdater } from '@/helpers/value-updater.js' + +export const useCommunityEmotesStatisticTable = defineStore('features/community-emotes-statistic-table', () => { + const { t } = useI18n() + const { pagination } = usePagination() + const { + debouncedSearchInput, + tableRange, + sortingState, + tableOrder, + } = storeToRefs(useCommunityEmotesStatisticFilters()) + + const emotesQueryOptions = computed((prevParams) => { + if (prevParams?.search !== debouncedSearchInput.value) { + pagination.value.pageIndex = 0 + } + + return { + search: debouncedSearchInput.value, + perPage: pagination.value.pageSize, + page: pagination.value.pageIndex, + graphicRange: tableRange.value, + order: tableOrder.value, + } + }) + const { data, fetching } = useEmotesStatisticQuery(emotesQueryOptions) + + const emotes = computed(() => { + if (!data.value) return [] + return data.value.emotesStatistics.emotes + }) + const totalEmotes = computed(() => data.value?.emotesStatistics.total ?? 0) + const pageCount = computed(() => { + return Math.ceil(totalEmotes.value / pagination.value.pageSize) + }) + + const statsColumn = computed[]>(() => [ + { + accessorKey: 'name', + size: 5, + header: () => h('div', {}, t('community.emotesStatistic.table.emote')), + cell: ({ row }) => { + return h('div', { class: 'break-words max-w-[450px]', innerHTML: row.original.emoteName }) + }, + }, + { + accessorKey: 'usages', + size: 5, + header: ({ column }) => { + return h(CommunityEmotesTableColumn, { + column, + title: t('community.emotesStatistic.table.usages'), + }) + }, + cell: ({ row }) => { + return h('div', `${row.original.totalUsages}`) + }, + }, + { + accessorKey: 'chart', + size: 80, + header: () => h(CommunityEmotesTableColumnChartRange), + cell: ({ row }) => { + return h(CommunityEmotesTableColumnChart, { + isDayRange: tableRange.value === EmoteStatisticRange.LastDay, + usages: row.original.graphicUsages, + }) + }, + }, + { + accessorKey: 'actions', + size: 10, + header: () => '', + cell: ({ row }) => { + return h(CommunityEmotesTableColumnActions, { emoteName: row.original.emoteName }) + }, + }, + ]) + + const table = useVueTable({ + get pageCount() { + return pageCount.value + }, + get data() { + return emotes.value + }, + get columns() { + return statsColumn.value + }, + state: { + get sorting() { + return sortingState.value + }, + get pagination() { + return pagination.value + }, + }, + manualPagination: true, + onPaginationChange: (updaterOrValue) => valueUpdater(updaterOrValue, pagination), + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sortingState), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }) + + return { + isLoading: fetching, + table, + totalEmotes, + pageCount, + pagination, + } +}) diff --git a/frontend/dashboard/src/features/community-emotes-statistic/composables/use-translated-ranges.ts b/frontend/dashboard/src/features/community-emotes-statistic/composables/use-translated-ranges.ts new file mode 100644 index 000000000..00291c16c --- /dev/null +++ b/frontend/dashboard/src/features/community-emotes-statistic/composables/use-translated-ranges.ts @@ -0,0 +1,21 @@ +import { defineStore } from 'pinia' +import { computed } from 'vue' +import { useI18n } from 'vue-i18n' + +import { EmoteStatisticRange } from '@/gql/graphql' + +export const useTranslatedRanges = defineStore('features/community-emotes-statistic-table/ranges', () => { + const { t } = useI18n() + + const ranges = computed(() => ({ + [EmoteStatisticRange.LastDay]: t('community.emotesStatistic.table.lastDay'), + [EmoteStatisticRange.LastWeek]: t('community.emotesStatistic.table.lastWeek'), + [EmoteStatisticRange.LastMonth]: t('community.emotesStatistic.table.lastMonth'), + [EmoteStatisticRange.LastThreeMonth]: t('community.emotesStatistic.table.lastThreeMonth'), + [EmoteStatisticRange.LastYear]: t('community.emotesStatistic.table.lastYear'), + })) + + return { + ranges, + } +}) diff --git a/frontend/dashboard/src/features/community-roles/community-roles.vue b/frontend/dashboard/src/features/community-roles/community-roles.vue index 8bebeecc4..f4293719b 100644 --- a/frontend/dashboard/src/features/community-roles/community-roles.vue +++ b/frontend/dashboard/src/features/community-roles/community-roles.vue @@ -18,7 +18,6 @@ import type { ChannelRolesQuery } from '@/gql/graphql' import { useUserAccessFlagChecker } from '@/api/index.js' import { useRoles } from '@/api/roles' import { ChannelRolePermissionEnum, RoleTypeEnum } from '@/gql/graphql' -import PageLayout from '@/layout/page-layout.vue' const rolesManager = useRoles() const { data: roles } = rolesManager.useRolesQuery() @@ -38,74 +37,67 @@ const { t } = useI18n() diff --git a/frontend/dashboard/src/layout/navigation.vue b/frontend/dashboard/src/layout/navigation.vue index 2bd8e7f17..3b545270c 100644 --- a/frontend/dashboard/src/layout/navigation.vue +++ b/frontend/dashboard/src/layout/navigation.vue @@ -8,43 +8,45 @@ import { IconCommand, IconDashboard, IconDeviceDesktop, - IconHammer, IconDeviceGamepad2, + IconHammer, IconHeadphones, IconKey, IconMessageCircle2, IconPencilPlus, - IconShieldHalfFilled, IconSpeakerphone, + IconSpeakerphone, IconSword, IconUsers, -} from '@tabler/icons-vue'; -import { MenuDividerOption, MenuOption, NBadge, NMenu } from 'naive-ui'; -import { computed, h, onMounted, ref } from 'vue'; -import { useI18n } from 'vue-i18n'; -import { RouterLink, useRouter } from 'vue-router'; +} from '@tabler/icons-vue' +import { NBadge, NMenu } from 'naive-ui' +import { computed, h, onMounted, ref } from 'vue' +import { useI18n } from 'vue-i18n' +import { RouterLink, useRouter } from 'vue-router' -import { renderIcon } from '../helpers/index.js'; +import { renderIcon } from '../helpers/index.js' -import { useUserAccessFlagChecker } from '@/api'; -import { ChannelRolePermissionEnum } from '@/gql/graphql'; +import type { MenuDividerOption, MenuOption } from 'naive-ui' -const { t } = useI18n(); +import { useUserAccessFlagChecker } from '@/api' +import { ChannelRolePermissionEnum } from '@/gql/graphql' -const activeKey = ref('/'); +const { t } = useI18n() -const canViewIntegrations = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewIntegrations); -const canViewEvents = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewEvents); -const canViewOverlays = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewOverlays); -const canViewSongRequests = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewSongRequests); -const canViewCommands = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewCommands); -const canViewTimers = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewTimers); -const canViewKeywords = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewKeywords); -const canViewVariables = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewVariables); -const canViewGreetings = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewGreetings); -const canViewRoles = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewRoles); -const canViewAlerts = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewAlerts); -const canViewGames = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewGames); -const canViewModeration = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewModeration); +const activeKey = ref('/') + +const canViewIntegrations = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewIntegrations) +const canViewEvents = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewEvents) +const canViewOverlays = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewOverlays) +const canViewSongRequests = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewSongRequests) +const canViewCommands = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewCommands) +const canViewTimers = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewTimers) +const canViewKeywords = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewKeywords) +const canViewVariables = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewVariables) +const canViewGreetings = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewGreetings) +// const canViewRoles = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewRoles) +const canViewAlerts = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewAlerts) +const canViewGames = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewGames) +const canViewModeration = useUserAccessFlagChecker(ChannelRolePermissionEnum.ViewModeration) const menuOptions = computed<(MenuOption | MenuDividerOption)[]>(() => { return [ @@ -120,15 +122,9 @@ const menuOptions = computed<(MenuOption | MenuDividerOption)[]>(() => { disabled: !canViewModeration.value, }, { - label: t('sidebar.users'), + label: t('sidebar.community'), icon: renderIcon(IconUsers), - path: '/dashboard/community/users', - }, - { - label: t('sidebar.roles'), - icon: renderIcon(IconShieldHalfFilled), - path: '/dashboard/community/roles', - disabled: !canViewRoles.value, + path: '/dashboard/community', }, { label: t('sidebar.timers'), @@ -158,50 +154,54 @@ const menuOptions = computed<(MenuOption | MenuDividerOption)[]>(() => { ...item, key: item.path ?? item.label, extra: item.disabled ? 'No perms' : undefined, - label: !item.path || item.disabled ? item.label ?? undefined : () => h( - RouterLink, - { - to: { - path: item.path, - }, - }, - { - default: () => item.isNew - ? h(NBadge, { - type: 'info', - value: 'new', - processing: true, - offset: [17, 5], - }, { default: () => item.label }) - : item.label, - }, - ), - children: item.children?.map((child) => ({ - ...child, - key: child.path, - label: item.disabled ? child.label : () => h( + label: !item.path || item.disabled + ? item.label ?? undefined + : () => h( RouterLink, { to: { - path: child.path, + path: item.path, }, }, - { default: () => child.label }, + { + default: () => item.isNew + ? h(NBadge, { + type: 'info', + value: 'new', + processing: true, + offset: [17, 5], + }, { default: () => item.label }) + : item.label, + }, ), + children: item.children?.map((child) => ({ + ...child, + key: child.path, + label: item.disabled + ? child.label + : () => h( + RouterLink, + { + to: { + path: child.path, + }, + }, + { default: () => child.label }, + ), })), - })); -}); + })) +}) -const router = useRouter(); +const router = useRouter() onMounted(async () => { - await router.isReady(); - activeKey.value = router.currentRoute.value.path; -}); + await router.isReady() + activeKey.value = router.currentRoute.value.path +})