From 2b92c989758a72521830817c3da532a4fcbc82b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janik=20Br=C3=BCll?= Date: Thu, 18 Apr 2024 23:01:20 +0200 Subject: [PATCH 1/3] :sparkles: Add Tdarr integration and widget (#1882) --- .../locales/en/modules/media-transcoding.json | 96 +++++ .../Appearance/AppearanceCustomization.tsx | 6 +- .../InputElements/IntegrationSelector.tsx | 5 + .../Tiles/Widgets/WidgetsEditModal.tsx | 3 +- .../Dashboard/Tiles/Widgets/WidgetsMenu.tsx | 2 +- src/server/api/root.ts | 2 + src/server/api/routers/tdarr.ts | 356 ++++++++++++++++++ src/tools/server/translation-namespaces.ts | 1 + src/types/api/tdarr.ts | 60 +++ src/types/app.ts | 4 +- src/widgets/index.ts | 2 + .../media-transcoding/HealthCheckStatus.tsx | 90 +++++ .../MediaTranscodingTile.tsx | 265 +++++++++++++ src/widgets/media-transcoding/QueuePanel.tsx | 69 ++++ .../media-transcoding/StatisticsPanel.tsx | 167 ++++++++ .../media-transcoding/WorkersPanel.tsx | 94 +++++ src/widgets/widgets.ts | 1 + 17 files changed, 1219 insertions(+), 4 deletions(-) create mode 100644 public/locales/en/modules/media-transcoding.json create mode 100644 src/server/api/routers/tdarr.ts create mode 100644 src/types/api/tdarr.ts create mode 100644 src/widgets/media-transcoding/HealthCheckStatus.tsx create mode 100644 src/widgets/media-transcoding/MediaTranscodingTile.tsx create mode 100644 src/widgets/media-transcoding/QueuePanel.tsx create mode 100644 src/widgets/media-transcoding/StatisticsPanel.tsx create mode 100644 src/widgets/media-transcoding/WorkersPanel.tsx diff --git a/public/locales/en/modules/media-transcoding.json b/public/locales/en/modules/media-transcoding.json new file mode 100644 index 00000000000..d391c54bf02 --- /dev/null +++ b/public/locales/en/modules/media-transcoding.json @@ -0,0 +1,96 @@ +{ + "descriptor": { + "name": "Media Transcoding", + "description": "Displays information about media transcoding", + "settings": { + "title": "Media Transcoding Settings", + "appId": { + "label": "Select an app" + }, + "defaultView": { + "label": "Default view", + "data": { + "workers": "Workers", + "queue": "Queue", + "statistics": "Statistics" + } + }, + "showHealthCheck": { + "label": "Show Health Check indicator" + }, + "showHealthChecksInQueue": { + "label": "Show Health Checks in queue" + }, + "queuePageSize": { + "label": "Queue: Items per page" + }, + "showAppIcon": { + "label": "Show app icon in the bottom right corner" + } + } + }, + "noAppSelected": "Please select an app in the widget settings", + "views": { + "workers": { + "table": { + "header": { + "name": "File", + "eta": "ETA", + "progress": "Progress" + }, + "empty": "Empty", + "tooltip": { + "transcode": "Transcode", + "healthCheck": "Health Check" + } + } + }, + "queue": { + "table": { + "header": { + "name": "File", + "size": "Size" + }, + "footer": { + "currentIndex": "{{start}}-{{end}} of {{total}}" + }, + "empty": "Empty", + "tooltip": { + "transcode": "Transcode", + "healthCheck": "Health Check" + } + } + }, + "statistics": { + "empty": "Empty", + "box": { + "transcodes": "Transcodes: {{value}}", + "healthChecks": "Health Checks: {{value}}", + "files": "Files: {{value}}", + "spaceSaved": "Saved: {{value}}" + }, + "pies": { + "transcodes": "Transcodes", + "healthChecks": "Health Checks", + "videoCodecs": "Codecs", + "videoContainers": "Containers", + "videoResolutions": "Resolutions" + } + } + }, + "error": { + "title": "Error", + "message": "An error occurred while fetching data from Tdarr." + }, + "tabs": { + "workers": "Workers", + "queue": "Queue", + "statistics": "Statistics" + }, + "healthCheckStatus": { + "title": "Health Check", + "queued": "Queued", + "healthy": "Healthy", + "unhealthy": "Unhealthy" + } +} diff --git a/src/components/Board/Customize/Appearance/AppearanceCustomization.tsx b/src/components/Board/Customize/Appearance/AppearanceCustomization.tsx index 4f1411124e8..ee595a55c2f 100644 --- a/src/components/Board/Customize/Appearance/AppearanceCustomization.tsx +++ b/src/components/Board/Customize/Appearance/AppearanceCustomization.tsx @@ -16,7 +16,11 @@ import { useTranslation } from 'next-i18next'; import { highlight, languages } from 'prismjs'; import Editor from 'react-simple-code-editor'; import { useColorTheme } from '~/tools/color'; -import { BackgroundImageAttachment, BackgroundImageRepeat, BackgroundImageSize } from '~/types/settings'; +import { + BackgroundImageAttachment, + BackgroundImageRepeat, + BackgroundImageSize, +} from '~/types/settings'; import { useBoardCustomizationFormContext } from '../form'; diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx index f6273209274..515c4b46701 100644 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx @@ -202,5 +202,10 @@ export const availableIntegrations = [ value: 'proxmox', image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/proxmox.png', label: 'Proxmox', + }, + { + value: 'tdarr', + image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tdarr.png', + label: 'Tdarr', } ] as const satisfies Readonly; diff --git a/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx b/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx index 6b6e11e1a48..f2a2a15dc57 100644 --- a/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx +++ b/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx @@ -85,7 +85,7 @@ export const WidgetsEditModal = ({ return ( - {items.map(([key, _], index) => { + {items.map(([key], index) => { const option = (currentWidgetDefinition as any).options[key] as IWidgetOptionValue; const value = moduleProperties[key] ?? option.defaultValue; @@ -395,6 +395,7 @@ const WidgetOptionTypeSwitch: FC<{ ); + /* eslint-enable no-case-declarations */ default: return null; diff --git a/src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx b/src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx index 88dfc827df0..1bab12d59d7 100644 --- a/src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx +++ b/src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx @@ -63,7 +63,7 @@ export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => { const handleEditClick = () => { openContextModalGeneric({ modal: 'integrationOptions', - title: {t('descriptor.settings.title')}, + title: t('descriptor.settings.title'), innerProps: { widgetId: widget.id, widgetType: integration, diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 053cc7d98f4..98f0ee68f55 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -22,6 +22,7 @@ import { smartHomeEntityStateRouter } from './routers/smart-home/entity-state'; import { usenetRouter } from './routers/usenet/router'; import { userRouter } from './routers/user'; import { weatherRouter } from './routers/weather'; +import { tdarrRouter } from '~/server/api/routers/tdarr'; /** * This is the primary router for your server. @@ -51,6 +52,7 @@ export const rootRouter = createTRPCRouter({ notebook: notebookRouter, smartHomeEntityState: smartHomeEntityStateRouter, healthMonitoring: healthMonitoringRouter, + tdarr: tdarrRouter, }); // export type definition of API diff --git a/src/server/api/routers/tdarr.ts b/src/server/api/routers/tdarr.ts new file mode 100644 index 00000000000..892464d0d51 --- /dev/null +++ b/src/server/api/routers/tdarr.ts @@ -0,0 +1,356 @@ +import { TRPCError } from '@trpc/server'; +import axios from 'axios'; +import { z } from 'zod'; +import { checkIntegrationsType } from '~/tools/client/app-properties'; +import { getConfig } from '~/tools/config/getConfig'; +import { ConfigAppType } from '~/types/app'; + +import { createTRPCRouter, publicProcedure } from '../trpc'; +import { TdarrQueue, TdarrStatistics, TdarrWorker } from '~/types/api/tdarr'; + +const getStatisticsSchema = z.object({ + totalFileCount: z.number(), + totalTranscodeCount: z.number(), + totalHealthCheckCount: z.number(), + table3Count: z.number(), + table6Count: z.number(), + table1Count: z.number(), + table4Count: z.number(), + pies: z.array( + z.tuple([ + z.string(), // Library Name + z.string(), // Library ID + z.number(), // File count + z.number(), // Number of transcodes + z.number(), // Space saved (in GB) + z.number(), // Number of health checks + z.array( + z.object({ + // Transcode Status (Pie segments) + name: z.string(), + value: z.number(), + }) + ), + z.array( + z.object({ + // Health Status (Pie segments) + name: z.string(), + value: z.number(), + }) + ), + z.array( + z.object({ + // Video files - Codecs (Pie segments) + name: z.string(), + value: z.number(), + }) + ), + z.array( + z.object({ + // Video files - Containers (Pie segments) + name: z.string(), + value: z.number(), + }) + ), + z.array( + z.object({ + // Video files - Resolutions (Pie segments) + name: z.string(), + value: z.number(), + }) + ), + z.array( + z.object({ + // Audio files - Codecs (Pie segments) + name: z.string(), + value: z.number(), + }) + ), + z.array( + z.object({ + // Audio files - Containers (Pie segments) + name: z.string(), + value: z.number(), + }) + ), + ]) + ), +}); + +const getNodesResponseSchema = z.record( + z.string(), + z.object({ + _id: z.string(), + nodeName: z.string(), + nodePaused: z.boolean(), + workers: z.record( + z.string(), + z.object({ + _id: z.string(), + file: z.string(), + fps: z.number(), + percentage: z.number(), + ETA: z.string(), + job: z.object({ + type: z.string(), + }), + status: z.string(), + lastPluginDetails: z + .object({ + number: z.string().optional(), + }) + .optional(), + originalfileSizeInGbytes: z.number(), + estSize: z.number().optional(), + outputFileSizeInGbytes: z.number().optional(), + workerType: z.string(), + }) + ), + }) +); + +const getStatusTableSchema = z.object({ + array: z.array( + z.object({ + _id: z.string(), + HealthCheck: z.string(), + TranscodeDecisionMaker: z.string(), + file: z.string(), + file_size: z.number(), + container: z.string(), + video_codec_name: z.string(), + video_resolution: z.string(), + }) + ), + totalCount: z.number(), +}); + +export const tdarrRouter = createTRPCRouter({ + statistics: publicProcedure + .input(z.object({ + appId: z.string(), + configName: z.string(), + })) + .query(async ({ input }): Promise => { + const app = getTdarrApp(input.appId, input.configName); + const appUrl = new URL('api/v2/cruddb', app.url); + + const body = { + data: { + collection: 'StatisticsJSONDB', + mode: 'getById', + docID: 'statistics', + }, + }; + + const res = await axios.post(appUrl.toString(), body); + const data: z.infer = res.data; + + const zodRes = getStatisticsSchema.safeParse(data); + if (!zodRes.success) { + /* + * Tdarr's API is not documented and had to be reverse engineered. To account for mistakes in the type + * definitions, we assume the best case scenario and log any parsing errors to aid in fixing the types. + */ + console.error(zodRes.error); + } + + return { + totalFileCount: data.totalFileCount, + totalTranscodeCount: data.totalTranscodeCount, + totalHealthCheckCount: data.totalHealthCheckCount, + failedTranscodeCount: data.table3Count, + failedHealthCheckCount: data.table6Count, + stagedTranscodeCount: data.table1Count, + stagedHealthCheckCount: data.table4Count, + pies: data.pies.map((pie) => ({ + libraryName: pie[0], + libraryId: pie[1], + totalFiles: pie[2], + totalTranscodes: pie[3], + savedSpace: pie[4] * 1_000_000_000, // file_size is in GB, convert to bytes, + totalHealthChecks: pie[5], + transcodeStatus: pie[6], + healthCheckStatus: pie[7], + videoCodecs: pie[8], + videoContainers: pie[9], + videoResolutions: pie[10], + audioCodecs: pie[11], + audioContainers: pie[12], + })), + }; + }), + + workers: publicProcedure + .input(z.object({ + appId: z.string(), + configName: z.string(), + })).query(async ({ input }): Promise => { + const app = getTdarrApp(input.appId, input.configName); + const appUrl = new URL('api/v2/get-nodes', app.url); + + const res = await axios.get(appUrl.toString()); + const data: z.infer = res.data; + + const zodRes = getNodesResponseSchema.safeParse(data); + if (!zodRes.success) { + /* + * Tdarr's API is not documented and had to be reverse engineered. To account for mistakes in the type + * definitions, we assume the best case scenario and log any parsing errors to aid in fixing the types. + */ + console.error(zodRes.error); + } + + const nodes = Object.values(data); + const workers = nodes.flatMap((node) => { + return Object.values(node.workers); + }); + + return workers.map((worker) => ({ + id: worker._id, + filePath: worker.file, + fps: worker.fps, + percentage: worker.percentage, + ETA: worker.ETA, + jobType: worker.job.type, + status: worker.status, + step: worker.lastPluginDetails?.number ?? '', + originalSize: worker.originalfileSizeInGbytes * 1_000_000_000, // file_size is in GB, convert to bytes, + estimatedSize: worker.estSize ? worker.estSize * 1_000_000_000 : null, // file_size is in GB, convert to bytes, + outputSize: worker.outputFileSizeInGbytes ? worker.outputFileSizeInGbytes * 1_000_000_000 : null, // file_size is in GB, convert to bytes, + })); + }), + + queue: publicProcedure + .input(z.object({ + appId: z.string(), + configName: z.string(), + showHealthChecksInQueue: z.boolean(), + pageSize: z.number(), + page: z.number(), + })) + .query(async ({ input }): Promise => { + const app = getTdarrApp(input.appId, input.configName); + + const appUrl = new URL('api/v2/client/status-tables', app.url); + + const { page, pageSize, showHealthChecksInQueue } = input; + + const firstItemIndex = page * pageSize; + + const transcodeQueueBody = { + data: { + start: firstItemIndex, + pageSize: pageSize, + filters: [], + sorts: [], + opts: { + table: 'table1', + }, + }, + }; + + const transcodeQueueRes = await axios.post(appUrl.toString(), transcodeQueueBody); + const transcodeQueueData: z.infer = transcodeQueueRes.data; + + const transcodeQueueZodRes = getStatusTableSchema.safeParse(transcodeQueueData); + if (!transcodeQueueZodRes.success) { + /* + * Tdarr's API is not documented and had to be reverse engineered. To account for mistakes in the type + * definitions, we assume the best case scenario and log any parsing errors to aid in fixing the types. + */ + console.error(transcodeQueueZodRes.error); + } + + const transcodeQueueResult = { + array: transcodeQueueData.array.map((item) => ({ + id: item._id, + healthCheck: item.HealthCheck, + transcode: item.TranscodeDecisionMaker, + filePath: item.file, + fileSize: item.file_size * 1_000_000, // file_size is in MB, convert to bytes + container: item.container, + codec: item.video_codec_name, + resolution: item.video_resolution, + type: 'transcode' as const, + })), + totalCount: transcodeQueueData.totalCount, + startIndex: firstItemIndex, + endIndex: firstItemIndex + transcodeQueueData.array.length - 1, + }; + + if (!showHealthChecksInQueue) { + return transcodeQueueResult; + } + + const healthCheckQueueBody = { + data: { + start: Math.max(firstItemIndex - transcodeQueueData.totalCount, 0), + pageSize: pageSize, + filters: [], + sorts: [], + opts: { + table: 'table4', + }, + }, + }; + + const healthCheckQueueRes = await axios.post(appUrl.toString(), healthCheckQueueBody); + const healthCheckQueueData: z.infer = healthCheckQueueRes.data; + + const healthCheckQueueZodRes = getStatusTableSchema.safeParse(healthCheckQueueData); + if (!healthCheckQueueZodRes.success) { + /* + * Tdarr's API is not documented and had to be reverse engineered. To account for mistakes in the type + * definitions, we assume the best case scenario and log any parsing errors to aid in fixing the types. + */ + console.error(healthCheckQueueZodRes.error); + } + + const healthCheckResultArray = healthCheckQueueData.array.map((item) => ({ + id: item._id, + healthCheck: item.HealthCheck, + transcode: item.TranscodeDecisionMaker, + filePath: item.file, + fileSize: item.file_size * 1_000_000, // file_size is in MB, convert to bytes + container: item.container, + codec: item.video_codec_name, + resolution: item.video_resolution, + type: 'health check' as const, + })); + + const combinedArray = [...transcodeQueueResult.array, ...healthCheckResultArray].slice( + 0, + pageSize + ); + + return { + array: combinedArray, + totalCount: transcodeQueueData.totalCount + healthCheckQueueData.totalCount, + startIndex: firstItemIndex, + endIndex: firstItemIndex + combinedArray.length - 1, + }; + }), +}); + +function getTdarrApp(appId: string, configName: string): ConfigAppType { + const config = getConfig(configName); + + const app = config.apps.find((x) => x.id === appId); + + if (!app) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `[Tdarr integration] App with ID "${appId}" could not be found.`, + }); + } + + if (!checkIntegrationsType(app.integration, ['tdarr'])) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `[Tdarr integration] App with ID "${appId}" is not using the Tdarr integration.`, + }); + } + + return app; +} diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index fe698859f4b..eeeeacc8d34 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -34,6 +34,7 @@ export const boardNamespaces = [ 'modules/notebook', 'modules/smart-home/entity-state', 'modules/smart-home/trigger-automation', + 'modules/media-transcoding', 'widgets/error-boundary', 'widgets/draggable-list', 'widgets/location', diff --git a/src/types/api/tdarr.ts b/src/types/api/tdarr.ts new file mode 100644 index 00000000000..751611b40d8 --- /dev/null +++ b/src/types/api/tdarr.ts @@ -0,0 +1,60 @@ +export type TdarrPieSegment = { + name: string; + value: number; +}; + +export type TdarrStatistics = { + totalFileCount: number; + totalTranscodeCount: number; + totalHealthCheckCount: number; + failedTranscodeCount: number; + failedHealthCheckCount: number; + stagedTranscodeCount: number; + stagedHealthCheckCount: number; + pies: { + libraryName: string; + libraryId: string; + totalFiles: number; + totalTranscodes: number; + savedSpace: number; + totalHealthChecks: number; + transcodeStatus: TdarrPieSegment[]; + healthCheckStatus: TdarrPieSegment[]; + videoCodecs: TdarrPieSegment[]; + videoContainers: TdarrPieSegment[]; + videoResolutions: TdarrPieSegment[]; + audioCodecs: TdarrPieSegment[]; + audioContainers: TdarrPieSegment[]; + }[]; +}; + +export type TdarrWorker = { + id: string; + filePath: string; + fps: number; + percentage: number; + ETA: string; + jobType: string; + status: string; + step: string; + originalSize: number; + estimatedSize: number | null; + outputSize: number | null; +}; + +export type TdarrQueue = { + array: { + id: string; + healthCheck: string; + transcode: string; + filePath: string; + fileSize: number; + container: string; + codec: string; + resolution: string; + type: 'transcode' | 'health check'; + }[]; + totalCount: number; + startIndex: number; + endIndex: number; +}; \ No newline at end of file diff --git a/src/types/app.ts b/src/types/app.ts index c161060089c..60f0093e990 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -59,7 +59,8 @@ export type IntegrationType = | 'adGuardHome' | 'homeAssistant' | 'openmediavault' - | 'proxmox'; + | 'proxmox' + | 'tdarr'; export type AppIntegrationType = { type: IntegrationType | null; @@ -105,6 +106,7 @@ export const integrationFieldProperties: { homeAssistant: ['apiKey'], openmediavault: ['username', 'password'], proxmox: ['apiKey'], + tdarr: [], }; export type IntegrationFieldDefinitionType = { diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 1666722bf8b..69ed3a31617 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -15,6 +15,7 @@ import notebook from './notebook/NotebookWidgetTile'; import rss from './rss/RssWidgetTile'; import smartHomeEntityState from './smart-home/entity-state/entity-state.widget'; import smartHomeTriggerAutomation from './smart-home/trigger-automation/trigger-automation.widget'; +import mediaTranscoding from '~/widgets/media-transcoding/MediaTranscodingTile'; import torrent from './torrent/TorrentTile'; import usenet from './useNet/UseNetTile'; import videoStream from './video/VideoStreamTile'; @@ -42,4 +43,5 @@ export default { 'smart-home/entity-state': smartHomeEntityState, 'smart-home/trigger-automation': smartHomeTriggerAutomation, 'health-monitoring': healthMonitoring, + 'media-transcoding': mediaTranscoding, }; diff --git a/src/widgets/media-transcoding/HealthCheckStatus.tsx b/src/widgets/media-transcoding/HealthCheckStatus.tsx new file mode 100644 index 00000000000..4bf91baa8dc --- /dev/null +++ b/src/widgets/media-transcoding/HealthCheckStatus.tsx @@ -0,0 +1,90 @@ +import { + Divider, + Group, + HoverCard, + Indicator, + MantineColor, + RingProgress, + Stack, + Text, +} from '@mantine/core'; +import { IconHeartbeat } from '@tabler/icons-react'; +import { useTranslation } from 'next-i18next'; +import { useColorScheme } from '~/hooks/use-colorscheme'; + +import { TdarrStatistics } from '~/types/api/tdarr'; + +interface StatisticsBadgeProps { + statistics?: TdarrStatistics; +} + +export function HealthCheckStatus(props: StatisticsBadgeProps) { + const { statistics } = props; + + const { colorScheme } = useColorScheme(); + const { t } = useTranslation('modules/media-transcoding'); + + if (!statistics) { + return ; + } + + const indicatorColor = statistics.failedHealthCheckCount + ? 'red' + : statistics.stagedHealthCheckCount + ? 'yellow' + : 'green'; + + return ( + + + + + + + + + + + {t(`healthCheckStatus.title`)} + + + + + + + {statistics.stagedHealthCheckCount} + + {t(`healthCheckStatus.queued`)} + + + + {statistics.totalHealthCheckCount} + + {t(`healthCheckStatus.healthy`)} + + + + {statistics.failedHealthCheckCount} + + {t(`healthCheckStatus.unhealthy`)} + + + + + + ); +} + +function textColor(color: MantineColor, theme: 'light' | 'dark') { + return `${color}.${theme === 'light' ? 8 : 5}`; +} diff --git a/src/widgets/media-transcoding/MediaTranscodingTile.tsx b/src/widgets/media-transcoding/MediaTranscodingTile.tsx new file mode 100644 index 00000000000..b388e7e0c70 --- /dev/null +++ b/src/widgets/media-transcoding/MediaTranscodingTile.tsx @@ -0,0 +1,265 @@ +import { + Alert, + Center, + Code, + Divider, + Group, + List, + Pagination, + SegmentedControl, + Stack, + Text, + Title, + Tooltip, +} from '@mantine/core'; +import { + IconAlertCircle, + IconClipboardList, + IconCpu2, + IconReportAnalytics, IconTransform, +} from '@tabler/icons-react'; +import { useTranslation } from 'next-i18next'; +import { useState } from 'react'; +import { z } from 'zod'; +import { AppAvatar } from '~/components/AppAvatar'; +import { useConfigContext } from '~/config/provider'; +import { api } from '~/utils/api'; +import { HealthCheckStatus } from '~/widgets/media-transcoding/HealthCheckStatus'; +import { QueuePanel } from '~/widgets/media-transcoding/QueuePanel'; +import { StatisticsPanel } from '~/widgets/media-transcoding/StatisticsPanel'; +import { WorkersPanel } from '~/widgets/media-transcoding/WorkersPanel'; + +import { defineWidget } from '../helper'; +import { IWidget } from '../widgets'; + +const definition = defineWidget({ + id: 'media-transcoding', + icon: IconTransform, + options: { + defaultView: { + type: 'select', + data: [ + { + value: 'workers', + }, + { + value: 'queue', + }, + { + value: 'statistics', + }, + ], + defaultValue: 'workers', + }, + showHealthCheck: { + type: 'switch', + defaultValue: true, + }, + showHealthChecksInQueue: { + type: 'switch', + defaultValue: true, + }, + queuePageSize: { + type: 'number', + defaultValue: 10, + }, + showAppIcon: { + type: 'switch', + defaultValue: true, + }, + }, + gridstack: { + minWidth: 3, + minHeight: 2, + maxWidth: 12, + maxHeight: 6, + }, + component: MediaTranscodingTile, +}); + +export type TdarrWidget = IWidget<(typeof definition)['id'], typeof definition>; + +interface TdarrQueueTileProps { + widget: TdarrWidget; +} + +function MediaTranscodingTile({ widget }: TdarrQueueTileProps) { + const { t } = useTranslation('modules/media-transcoding'); + const { config, name: configName } = useConfigContext(); + + const appId = config?.apps.find( + (app) => app.integration.type === 'tdarr', + )?.id; + const app = config?.apps.find((app) => app.id === appId); + const { defaultView, showHealthCheck, showHealthChecksInQueue, queuePageSize, showAppIcon } = + widget.properties; + + const [view, setView] = useState<'workers' | 'queue' | 'statistics'>( + viewSchema.parse(defaultView) + ); + + const [queuePage, setQueuePage] = useState(1); + + const workers = api.tdarr.workers.useQuery( + { + appId: app?.id!, + configName: configName!, + }, + { enabled: !!app?.id && !!configName && view === 'workers', refetchInterval: 2000 } + ); + + const statistics = api.tdarr.statistics.useQuery( + { + appId: app?.id!, + configName: configName!, + }, + { enabled: !!app?.id && !!configName, refetchInterval: 10000 } + ); + + const queue = api.tdarr.queue.useQuery( + { + appId: app?.id!, + configName: configName!, + pageSize: queuePageSize, + page: queuePage - 1, + showHealthChecksInQueue, + }, + { + enabled: !!app?.id && !!configName && view === 'queue', + refetchInterval: 2000, + } + ); + + if (statistics.isError || workers.isError || queue.isError) { + return ( + + } + my="lg" + title={t('error.title')} + color="red" + radius="md" + > + {t('error.message')} + + {statistics.isError && ( + + {statistics.error.message} + + )} + {workers.isError && ( + + {workers.error.message} + + )} + {queue.isError && ( + + {queue.error.message} + + )} + + + + ); + } + + if (!app) { + return ( + +
+ {t('noAppSelected')} +
+
+ ); + } + + const totalQueuePages = Math.ceil((queue.data?.totalCount || 1) / queuePageSize); + + return ( + + {view === 'workers' ? ( + + ) : view === 'queue' ? ( + + ) : ( + + )} + + + + + + {t('tabs.workers')} + + + ), + value: 'workers', + }, + { + label: ( +
+ + + {t('tabs.queue')} + +
+ ), + value: 'queue', + }, + { + label: ( +
+ + + {t('tabs.statistics')} + +
+ ), + value: 'statistics', + }, + ]} + value={view} + onChange={(value) => setView(viewSchema.parse(value))} + size="xs" + /> + {view === 'queue' && !!queue.data && ( + <> + + + + + + + + + + {t('views.queue.table.footer.currentIndex', { + start: queue.data.startIndex + 1, + end: queue.data.endIndex + 1, + total: queue.data.totalCount, + })} + + + )} + + {showHealthCheck && statistics.data && } + {showAppIcon && ( + +
+ +
+
+ )} +
+
+
+ ); +} + +const viewSchema = z.enum(['workers', 'queue', 'statistics']); + +export default definition; diff --git a/src/widgets/media-transcoding/QueuePanel.tsx b/src/widgets/media-transcoding/QueuePanel.tsx new file mode 100644 index 00000000000..bb7a1c5ed7c --- /dev/null +++ b/src/widgets/media-transcoding/QueuePanel.tsx @@ -0,0 +1,69 @@ +import { Center, Group, ScrollArea, Table, Text, Title, Tooltip } from '@mantine/core'; +import { IconHeartbeat, IconTransform } from '@tabler/icons-react'; +import { useTranslation } from 'next-i18next'; +import { humanFileSize } from '~/tools/humanFileSize'; +import { WidgetLoading } from '~/widgets/loading'; +import { TdarrQueue } from '~/types/api/tdarr'; + +interface QueuePanelProps { + queue: TdarrQueue | undefined; + isLoading: boolean; +} + +export function QueuePanel(props: QueuePanelProps) { + const { queue, isLoading } = props; + + const { t } = useTranslation('modules/media-transcoding'); + + if (isLoading) { + return ; + } + + if (!queue?.array.length) { + return ( +
+ {t('views.queue.table.empty')} +
+ ); + } + + return ( + + + + + + + + + + {queue.array.map((item) => ( + + + + + ))} + +
{t('views.queue.table.header.name')}{t('views.queue.table.header.size')}
+ +
+ {item.type === 'transcode' ? ( + + + + ) : ( + + + + )} +
+ {item.filePath.split('\\').pop()?.split('/').pop() ?? item.filePath} +
+
+ {humanFileSize(item.fileSize)} +
+
+ ); +} diff --git a/src/widgets/media-transcoding/StatisticsPanel.tsx b/src/widgets/media-transcoding/StatisticsPanel.tsx new file mode 100644 index 00000000000..3a156436922 --- /dev/null +++ b/src/widgets/media-transcoding/StatisticsPanel.tsx @@ -0,0 +1,167 @@ +import { + Box, + Center, + Grid, + Group, + MantineColor, + RingProgress, + RingProgressProps, + Stack, + Text, + Title, +} from '@mantine/core'; +import { + IconDatabaseHeart, + IconFileDescription, + IconHeartbeat, + IconTransform, +} from '@tabler/icons-react'; +import { useTranslation } from 'next-i18next'; +import { ReactNode } from 'react'; +import { humanFileSize } from '~/tools/humanFileSize'; +import { WidgetLoading } from '~/widgets/loading'; +import { TdarrPieSegment, TdarrStatistics } from '~/types/api/tdarr'; + +const PIE_COLORS: MantineColor[] = ['cyan', 'grape', 'gray', 'orange', 'pink']; + +interface StatisticsPanelProps { + statistics: TdarrStatistics | undefined; + isLoading: boolean; +} + +export function StatisticsPanel(props: StatisticsPanelProps) { + const { statistics, isLoading } = props; + + const { t } = useTranslation('modules/media-transcoding'); + + if (isLoading) { + return ; + } + + const allLibs = statistics?.pies.find((pie) => pie.libraryName === 'All'); + + if (!statistics || !allLibs) { + return ( +
+ {t('views.statistics.empty')} +
+ ); + } + + return ( + + + + + {t('views.statistics.pies.transcodes')} + + + + } + label={t('views.statistics.box.transcodes', { + value: statistics.totalTranscodeCount + })} + /> + + + } + label={t('views.statistics.box.healthChecks', { + value: statistics.totalHealthCheckCount + })} + /> + + + } + label={t('views.statistics.box.files', { + value: statistics.totalFileCount + })} + /> + + + } + label={t('views.statistics.box.spaceSaved', { + value: allLibs?.savedSpace ? humanFileSize(allLibs.savedSpace) : '-' + })} + /> + + + + + {t('views.statistics.pies.healthChecks')} + + + + + + {t('views.statistics.pies.videoCodecs')} + + + + {t('views.statistics.pies.videoContainers')} + + + + {t('views.statistics.pies.videoResolutions')} + + + + ); +} + +function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps['sections'] { + const total = segments.reduce((prev, curr) => prev + curr.value , 0); + return segments.map((segment, index) => ({ + value: (segment.value * 100) / total, + tooltip: `${segment.name}: ${segment.value}`, + color: PIE_COLORS[index % PIE_COLORS.length], // Ensures a valid color in the case that index > PIE_COLORS.length + })); +} + +type StatBoxProps = { + icon: ReactNode; + label: string; +}; + +function StatBox(props: StatBoxProps) { + const { icon, label } = props; + return ( + ({ + padding: theme.spacing.xs, + border: '1px solid', + borderRadius: theme.radius.md, + borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[3] : theme.colors.gray[1], + })} + > + + {icon} + + {label} + + + + ); +} diff --git a/src/widgets/media-transcoding/WorkersPanel.tsx b/src/widgets/media-transcoding/WorkersPanel.tsx new file mode 100644 index 00000000000..b07250969ac --- /dev/null +++ b/src/widgets/media-transcoding/WorkersPanel.tsx @@ -0,0 +1,94 @@ +import { + Center, + Group, + Progress, + ScrollArea, + Table, + Text, + Title, + Tooltip, +} from '@mantine/core'; +import { IconHeartbeat, IconTransform } from '@tabler/icons-react'; +import { useTranslation } from 'next-i18next'; +import { WidgetLoading } from '~/widgets/loading'; +import { TdarrWorker } from '~/types/api/tdarr'; + +interface WorkersPanelProps { + workers: TdarrWorker[] | undefined; + isLoading: boolean; +} + +export function WorkersPanel(props: WorkersPanelProps) { + const { workers, isLoading } = props; + + const { t } = useTranslation('modules/media-transcoding'); + + if (isLoading) { + return ; + } + + if (!workers?.length) { + return ( +
+ {t('views.workers.table.empty')} +
+ ); + } + + return ( + + + + + + + + + + + {workers.map((worker) => ( + + + + + + ))} + +
{t('views.workers.table.header.name')}{t('views.workers.table.header.eta')}{t('views.workers.table.header.progress')}
+ +
+ {worker.jobType === 'transcode' ? ( + + + + ) : ( + + + + )} +
+ {worker.filePath.split('\\').pop()?.split('/').pop() ?? worker.filePath} +
+
+ + {worker.ETA.startsWith('0:') ? worker.ETA.substring(2) : worker.ETA} + + + + {worker.step} + + {Math.round(worker.percentage)}% + +
+
+ ); +} diff --git a/src/widgets/widgets.ts b/src/widgets/widgets.ts index b77a9251c10..5cff6af0ae5 100644 --- a/src/widgets/widgets.ts +++ b/src/widgets/widgets.ts @@ -8,6 +8,7 @@ import { } from '@mantine/core'; import { Icon } from '@tabler/icons-react'; import React from 'react'; +import { IntegrationType } from '~/types/app'; import { AreaType } from '~/types/area'; import { ShapeType } from '~/types/shape'; From fdbb8d8b35b33195d1a694dcddff82365cbd1236 Mon Sep 17 00:00:00 2001 From: SeDemal Date: Sat, 20 Apr 2024 18:41:54 +0200 Subject: [PATCH 2/3] refactor: polish UI and improve popover interactions (#2016) --- .../locales/en/modules/torrents-status.json | 7 +++ src/widgets/torrent/TorrentTile.tsx | 55 +++++++++++++++---- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/public/locales/en/modules/torrents-status.json b/public/locales/en/modules/torrents-status.json index ffb8df33130..cb9ce18761e 100644 --- a/public/locales/en/modules/torrents-status.json +++ b/public/locales/en/modules/torrents-status.json @@ -30,9 +30,16 @@ "label": "Display filtered torrents list ratio", "info": "If disabled, only the global ratio will be display. The global ratio will still use the labels if set" }, + "columnOrdering":{ + "label": "Enable reordering the columns" + }, + "rowSorting":{ + "label": "Enable sorting the rows" + }, "columns": { "label": "Select columns to display", "data": { + "date": "Date Added", "down": "Down", "up": "Up", "eta": "ETA", diff --git a/src/widgets/torrent/TorrentTile.tsx b/src/widgets/torrent/TorrentTile.tsx index 6971787e10c..4111489b4a2 100644 --- a/src/widgets/torrent/TorrentTile.tsx +++ b/src/widgets/torrent/TorrentTile.tsx @@ -17,7 +17,7 @@ import duration from 'dayjs/plugin/duration'; import relativeTime from 'dayjs/plugin/relativeTime'; import { type MRT_ColumnDef, MRT_TableContainer, useMantineReactTable } from 'mantine-react-table'; import { useTranslation } from 'next-i18next'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { MIN_WIDTH_MOBILE } from '~/constants/constants'; import { calculateETA } from '~/tools/client/calculateEta'; import { humanFileSize } from '~/tools/humanFileSize'; @@ -69,10 +69,24 @@ const definition = defineWidget({ defaultValue: true, info: true, }, + columnOrdering: { + type: 'switch', + defaultValue: true, + }, + rowSorting: { + type: 'switch', + defaultValue: true, + }, columns: { type: 'multi-select', defaultValue: ['up', 'down', 'eta', 'progress'], - data: [{ value: 'up' }, { value: 'down' }, { value: 'eta' }, { value: 'progress' }], + data: [ + { value: 'up' }, + { value: 'down' }, + { value: 'eta' }, + { value: 'progress' }, + { value: 'date' }, + ], }, nameColumnSize: { type: 'slider', @@ -127,12 +141,20 @@ function TorrentTile({ widget }: TorrentTileProps) { const ratioGlobal = getTorrentsRatio(widget, torrents, false); const ratioWithFilter = getTorrentsRatio(widget, torrents, true); + const [opened, setOpened] = useState(-1); + const columns = useMemo[]>( () => [ { id: 'dateAdded', accessorFn: (row) => new Date(row.dateAdded), - header: 'dateAdded', + Cell: ({ cell }) => ( + + {dayjs(cell.getValue() as Date).format('YYYY/MM/DD')} + {dayjs(cell.getValue() as Date).format('HH:mm')} + + ), + header: t('card.table.header.dateAdded'), maxSize: 1, }, { @@ -147,6 +169,8 @@ function TorrentTile({ widget }: TorrentTileProps) { transitionProps={{ transition: 'pop', }} + opened={opened === row.index} + onChange={(o) => setOpened(() => (o ? row.index : -1))} > @@ -193,7 +217,7 @@ function TorrentTile({ widget }: TorrentTileProps) { header: t('card.table.header.progress'), maxSize: 1, Cell: ({ cell, row }) => ( - + {(Number(cell.getValue()) * 100).toPrecision(3)}% @@ -207,15 +231,14 @@ function TorrentTile({ widget }: TorrentTileProps) { : 'blue' } value={Number(cell.getValue()) * 100} - size="lg" + size="md" /> - , ), sortDescFirst: true, }, ], - [] + [opened] ); const torrentsTable = useMantineReactTable({ @@ -228,9 +251,19 @@ function TorrentTile({ widget }: TorrentTileProps) { enableColumnFilters: false, enableRowVirtualization: true, rowVirtualizerProps: { overscan: 20 }, - mantineTableContainerProps: { sx: { scrollbarWidth: 'none' } }, - enableColumnOrdering: true, - enableSorting: true, + mantineTableContainerProps: { sx: { scrollbarWidth: 'none', flex: '1', borderRadius: '0.5rem' } }, + mantineTableBodyCellProps: { style: { background: 'transparent' } }, + mantineTableHeadCellProps: { + style: { borderTopLeftRadius: '0.5rem', borderTopRightRadius: '0.5rem' }, + }, + mantineTableHeadRowProps: { + style: { borderTopLeftRadius: '0.5rem', borderTopRightRadius: '0.5rem' }, + }, + mantineTableBodyRowProps: ({ row }) => ({ + onClick: () => setOpened((o) => (o === row.index ? -1 : row.index)), + }), + enableColumnOrdering: widget.properties.columnOrdering, + enableSorting: widget.properties.rowSorting, initialState: { showColumnFilters: false, showGlobalFilter: false, @@ -250,7 +283,7 @@ function TorrentTile({ widget }: TorrentTileProps) { density: 'xs', columnVisibility: { isCompleted: false, - dateAdded: false, + dateAdded: widget.properties.columns.includes('date') && width > MIN_WIDTH_MOBILE, uploadSpeed: widget.properties.columns.includes('up') && width > MIN_WIDTH_MOBILE, downloadSpeed: widget.properties.columns.includes('down') && width > MIN_WIDTH_MOBILE, eta: widget.properties.columns.includes('eta') && width > MIN_WIDTH_MOBILE, From 7dffe393ab84d997ae72ef9a0f5b920ae224d983 Mon Sep 17 00:00:00 2001 From: SeDemal Date: Sat, 20 Apr 2024 18:43:46 +0200 Subject: [PATCH 3/3] fix: Modals titles nested headers and header nested buttons errors (#2019) --- .../Widgets/Inputs/LocationSelection.tsx | 12 +-- .../Dashboard/Tiles/Widgets/WidgetsMenu.tsx | 3 +- .../Dashboard/Wrappers/Category/Category.tsx | 6 +- .../Manage/Board/create-board.modal.tsx | 8 +- .../Manage/Board/delete-board.modal.tsx | 8 +- .../Docker/docker-select-board.modal.tsx | 8 +- .../Manage/User/Invite/copy-invite.modal.tsx | 8 +- .../User/Invite/create-invite.modal.tsx | 8 +- .../Manage/User/change-user-role.modal.tsx | 12 +-- .../Manage/User/delete-user.modal.tsx | 8 +- src/components/layout/header/AvatarMenu.tsx | 102 +++++++++--------- src/styles/global.scss | 5 + 12 files changed, 77 insertions(+), 111 deletions(-) diff --git a/src/components/Dashboard/Tiles/Widgets/Inputs/LocationSelection.tsx b/src/components/Dashboard/Tiles/Widgets/Inputs/LocationSelection.tsx index 7218196a098..f60a59fe0fb 100644 --- a/src/components/Dashboard/Tiles/Widgets/Inputs/LocationSelection.tsx +++ b/src/components/Dashboard/Tiles/Widgets/Inputs/LocationSelection.tsx @@ -169,11 +169,7 @@ const CitySelectModal = ({ opened, closeModal, query, onCitySelected }: CitySele if (isError === true) return ( - {t('modal.title')} - {query} - - } + title={t('modal.title') + ' - ' + query} size="xl" opened={opened} onClose={closeModal} @@ -193,11 +189,7 @@ const CitySelectModal = ({ opened, closeModal, query, onCitySelected }: CitySele return ( - {t('modal.title')} - {query} - - } + title={t('modal.title') + ' - ' + query} size="xl" opened={opened} onClose={closeModal} diff --git a/src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx b/src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx index 1bab12d59d7..36d54482760 100644 --- a/src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx +++ b/src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx @@ -1,4 +1,3 @@ -import { Title } from '@mantine/core'; import { useTranslation } from 'next-i18next'; import { openContextModalGeneric } from '~/tools/mantineModalManagerExtensions'; import { IWidget } from '~/widgets/widgets'; @@ -38,7 +37,7 @@ export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => { const handleDeleteClick = () => { openContextModalGeneric({ modal: 'integrationRemove', - title: {t('common:remove')}, + title: t('common:remove'), innerProps: { widgetId: widget.id, widgetType: integration, diff --git a/src/components/Dashboard/Wrappers/Category/Category.tsx b/src/components/Dashboard/Wrappers/Category/Category.tsx index 72d162c49e4..0aa51ec2d00 100644 --- a/src/components/Dashboard/Wrappers/Category/Category.tsx +++ b/src/components/Dashboard/Wrappers/Category/Category.tsx @@ -98,10 +98,10 @@ export const DashboardCategory = ({ category }: DashboardCategoryProps) => { > - }> + {category.name} - {!isEditMode && ( + {!isEditMode ? ( @@ -114,7 +114,7 @@ export const DashboardCategory = ({ category }: DashboardCategoryProps) => { - )} + ) : }
) => { export const openCreateBoardModal = () => { modals.openContextModal({ modal: 'createBoardModal', - title: ( - - <Trans i18nKey="manage/boards:modals.create.title" /> - - ), + title: , innerProps: {}, }); }; diff --git a/src/components/Manage/Board/delete-board.modal.tsx b/src/components/Manage/Board/delete-board.modal.tsx index cddb7c9bb2e..ce9521780be 100644 --- a/src/components/Manage/Board/delete-board.modal.tsx +++ b/src/components/Manage/Board/delete-board.modal.tsx @@ -1,4 +1,4 @@ -import { Button, Group, Stack, Text, Title } from '@mantine/core'; +import { Button, Group, Stack, Text } from '@mantine/core'; import { ContextModalProps, modals } from '@mantine/modals'; import { Trans, useTranslation } from 'next-i18next'; import { api } from '~/utils/api'; @@ -51,11 +51,7 @@ export const DeleteBoardModal = ({ id, innerProps }: ContextModalProps { modals.openContextModal({ modal: 'deleteBoardModal', - title: ( - - <Trans i18nKey="manage/boards:modals.delete.title" /> - - ), + title: , innerProps, }); }; diff --git a/src/components/Manage/Tools/Docker/docker-select-board.modal.tsx b/src/components/Manage/Tools/Docker/docker-select-board.modal.tsx index 688c6796ce0..590393e2319 100644 --- a/src/components/Manage/Tools/Docker/docker-select-board.modal.tsx +++ b/src/components/Manage/Tools/Docker/docker-select-board.modal.tsx @@ -1,4 +1,4 @@ -import { Button, Group, Select, Stack, Text, Title } from '@mantine/core'; +import { Button, Group, Select, Stack, Text } from '@mantine/core'; import { useForm } from '@mantine/form'; import { ContextModalProps, modals } from '@mantine/modals'; import { showNotification } from '@mantine/notifications'; @@ -132,11 +132,7 @@ export const DockerSelectBoardModal = ({ id, innerProps }: ContextModalProps { modals.openContextModal({ modal: 'dockerSelectBoardModal', - title: ( - - <Trans i18nKey="tools/docker:modals.selectBoard.title" /> - - ), + title: , innerProps, }); umami.track('Add to homarr modal'); diff --git a/src/components/Manage/User/Invite/copy-invite.modal.tsx b/src/components/Manage/User/Invite/copy-invite.modal.tsx index 44551e6b1db..efa318fb104 100644 --- a/src/components/Manage/User/Invite/copy-invite.modal.tsx +++ b/src/components/Manage/User/Invite/copy-invite.modal.tsx @@ -1,4 +1,4 @@ -import { Button, CopyButton, Mark, Stack, Text, Title } from '@mantine/core'; +import { Button, CopyButton, Mark, Stack, Text } from '@mantine/core'; import { ContextModalProps, modals } from '@mantine/modals'; import { Trans, useTranslation } from 'next-i18next'; import Link from 'next/link'; @@ -65,11 +65,7 @@ const useInviteUrl = (id: string, token: string) => { export const openCopyInviteModal = (data: InnerProps) => { modals.openContextModal({ modal: 'copyInviteModal', - title: ( - - <Trans i18nKey="manage/users/invites:modals.copy.title" /> - - ), + title: , innerProps: data, }); }; diff --git a/src/components/Manage/User/Invite/create-invite.modal.tsx b/src/components/Manage/User/Invite/create-invite.modal.tsx index 972a210a2c9..386e5df6911 100644 --- a/src/components/Manage/User/Invite/create-invite.modal.tsx +++ b/src/components/Manage/User/Invite/create-invite.modal.tsx @@ -1,4 +1,4 @@ -import { Button, Group, Stack, Text, Title } from '@mantine/core'; +import { Button, Group, Stack, Text } from '@mantine/core'; import { DateTimePicker } from '@mantine/dates'; import { useForm } from '@mantine/form'; import { ContextModalProps, modals } from '@mantine/modals'; @@ -79,11 +79,7 @@ export const CreateInviteModal = ({ id }: ContextModalProps<{}>) => { export const openCreateInviteModal = () => { modals.openContextModal({ modal: 'createInviteModal', - title: ( - - <Trans i18nKey="manage/users/invites:modals.create.title" /> - - ), + title: , innerProps: {}, }); }; diff --git a/src/components/Manage/User/change-user-role.modal.tsx b/src/components/Manage/User/change-user-role.modal.tsx index 97c2efc36e5..a5c691e93b1 100644 --- a/src/components/Manage/User/change-user-role.modal.tsx +++ b/src/components/Manage/User/change-user-role.modal.tsx @@ -1,4 +1,4 @@ -import { Button, Group, Stack, Text, Title } from '@mantine/core'; +import { Button, Group, Stack, Text } from '@mantine/core'; import { ContextModalProps, modals } from '@mantine/modals'; import { Trans, useTranslation } from 'next-i18next'; import { api } from '~/utils/api'; @@ -48,12 +48,10 @@ export const openRoleChangeModal = (user: InnerProps) => { modals.openContextModal({ modal: 'changeUserRoleModal', title: ( - - <Trans - i18nKey={`manage/users:modals.change-role.${user.type}.title`} - values={{ name: user.name }} - /> - + ), innerProps: user, }); diff --git a/src/components/Manage/User/delete-user.modal.tsx b/src/components/Manage/User/delete-user.modal.tsx index adbeae1ce64..3f6287c9633 100644 --- a/src/components/Manage/User/delete-user.modal.tsx +++ b/src/components/Manage/User/delete-user.modal.tsx @@ -1,4 +1,4 @@ -import { Button, Group, Stack, Text, Title } from '@mantine/core'; +import { Button, Group, Stack, Text } from '@mantine/core'; import { ContextModalProps, modals } from '@mantine/modals'; import { Trans, useTranslation } from 'next-i18next'; import { api } from '~/utils/api'; @@ -46,11 +46,7 @@ export const DeleteUserModal = ({ id, innerProps }: ContextModalProps { modals.openContextModal({ modal: 'deleteUserModal', - title: ( - - <Trans i18nKey="manage/users:modals.delete.title" values={{ name: user.name }} /> - - ), + title: , innerProps: user, }); }; diff --git a/src/components/layout/header/AvatarMenu.tsx b/src/components/layout/header/AvatarMenu.tsx index f9d6b4d0de4..1b3b7fb19ae 100644 --- a/src/components/layout/header/AvatarMenu.tsx +++ b/src/components/layout/header/AvatarMenu.tsx @@ -27,64 +27,60 @@ export const AvatarMenu = () => { const defaultBoardHref = useBoardLink('/board'); return ( - - - + + + - - - } - onClick={toggleColorScheme} - > - {t('actions.avatar.switchTheme')} - - {sessionData?.user && ( - <> - } - > - {t('actions.avatar.preferences')} - - } - > - {t('actions.avatar.defaultBoard')} - - }> - {t('actions.avatar.manage')} - - - - )} - {sessionData?.user ? ( + + + + } onClick={toggleColorScheme}> + {t('actions.avatar.switchTheme')} + + {sessionData?.user && ( + <> + } + > + {t('actions.avatar.preferences')} + } - color="red" - onClick={() => - signOut({ - redirect: false, - }).then(() => window.location.reload()) - } + component={Link} + href={defaultBoardHref} + icon={} > - {t('actions.avatar.logout', { - username: sessionData.user.name, - })} + {t('actions.avatar.defaultBoard')} - ) : ( - } component={Link} href="/auth/login"> - {t('actions.avatar.login')} + }> + {t('actions.avatar.manage')} - )} - - - + + + )} + {sessionData?.user ? ( + } + color="red" + onClick={() => + signOut({ + redirect: false, + }).then(() => window.location.reload()) + } + > + {t('actions.avatar.logout', { + username: sessionData.user.name, + })} + + ) : ( + } component={Link} href="/auth/login"> + {t('actions.avatar.login')} + + )} + + ); }; diff --git a/src/styles/global.scss b/src/styles/global.scss index 05e137588eb..6be988fc9be 100644 --- a/src/styles/global.scss +++ b/src/styles/global.scss @@ -140,6 +140,11 @@ background-size: 60px 60px; } +.mantine-Modal-title { + font-size: 1.375rem; + font-weight: 700; +} + .tiptap { hr { border-top-style: double;