mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat(ui): request card progress bar (#3123)
This commit is contained in:
@@ -158,7 +158,12 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
||||
try {
|
||||
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
|
||||
`/queue`
|
||||
`/queue`,
|
||||
{
|
||||
params: {
|
||||
includeEpisode: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.records;
|
||||
|
@@ -13,6 +13,21 @@ interface SonarrSeason {
|
||||
percentOfEpisodes: number;
|
||||
};
|
||||
}
|
||||
interface EpisodeResult {
|
||||
seriesId: number;
|
||||
episodeFileId: number;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
title: string;
|
||||
airDate: string;
|
||||
airDateUtc: string;
|
||||
overview: string;
|
||||
hasFile: boolean;
|
||||
monitored: boolean;
|
||||
absoluteEpisodeNumber: number;
|
||||
unverifiedSceneNumbering: boolean;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface SonarrSeries {
|
||||
title: string;
|
||||
@@ -82,7 +97,11 @@ export interface LanguageProfile {
|
||||
name: string;
|
||||
}
|
||||
|
||||
class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
||||
class SonarrAPI extends ServarrBase<{
|
||||
seriesId: number;
|
||||
episodeId: number;
|
||||
episode: EpisodeResult;
|
||||
}> {
|
||||
constructor({ url, apiKey }: { url: string; apiKey: string }) {
|
||||
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
|
||||
}
|
||||
|
@@ -5,6 +5,12 @@ import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { uniqWith } from 'lodash';
|
||||
|
||||
interface EpisodeNumberResult {
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
absoluteEpisodeNumber: number;
|
||||
id: number;
|
||||
}
|
||||
export interface DownloadingItem {
|
||||
mediaType: MediaType;
|
||||
externalId: number;
|
||||
@@ -14,6 +20,7 @@ export interface DownloadingItem {
|
||||
timeLeft: string;
|
||||
estimatedCompletionTime: Date;
|
||||
title: string;
|
||||
episode?: EpisodeNumberResult;
|
||||
}
|
||||
|
||||
class DownloadTracker {
|
||||
@@ -164,6 +171,7 @@ class DownloadTracker {
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
episode: item.episode,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
|
@@ -16,7 +16,7 @@ import type { Collection } from '@server/models/Collection';
|
||||
import { uniq } from 'lodash';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
|
||||
@@ -51,6 +51,28 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
const { data: genres } =
|
||||
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
|
||||
|
||||
const [downloadStatus, downloadStatus4k] = useMemo(() => {
|
||||
return [
|
||||
data?.parts.flatMap((item) =>
|
||||
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
|
||||
),
|
||||
data?.parts.flatMap((item) =>
|
||||
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
|
||||
),
|
||||
];
|
||||
}, [data?.parts]);
|
||||
|
||||
const [titles, titles4k] = useMemo(() => {
|
||||
return [
|
||||
data?.parts
|
||||
.filter((media) => (media.mediaInfo?.downloadStatus ?? []).length > 0)
|
||||
.map((title) => title.title),
|
||||
data?.parts
|
||||
.filter((media) => (media.mediaInfo?.downloadStatus4k ?? []).length > 0)
|
||||
.map((title) => title.title),
|
||||
];
|
||||
}, [data?.parts]);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -205,6 +227,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
<div className="media-status">
|
||||
<StatusBadge
|
||||
status={collectionStatus}
|
||||
downloadItem={downloadStatus}
|
||||
title={titles}
|
||||
inProgress={data.parts.some(
|
||||
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
|
||||
)}
|
||||
@@ -218,6 +242,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
) && (
|
||||
<StatusBadge
|
||||
status={collectionStatus4k}
|
||||
downloadItem={downloadStatus4k}
|
||||
title={titles4k}
|
||||
is4k
|
||||
inProgress={data.parts.some(
|
||||
(part) =>
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import type { Config } from 'react-popper-tooltip';
|
||||
import { usePopperTooltip } from 'react-popper-tooltip';
|
||||
|
||||
@@ -6,9 +7,15 @@ type TooltipProps = {
|
||||
content: React.ReactNode;
|
||||
children: React.ReactElement;
|
||||
tooltipConfig?: Partial<Config>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Tooltip = ({ children, content, tooltipConfig }: TooltipProps) => {
|
||||
const Tooltip = ({
|
||||
children,
|
||||
content,
|
||||
tooltipConfig,
|
||||
className,
|
||||
}: TooltipProps) => {
|
||||
const { getTooltipProps, setTooltipRef, setTriggerRef, visible } =
|
||||
usePopperTooltip({
|
||||
followCursor: true,
|
||||
@@ -17,20 +24,30 @@ const Tooltip = ({ children, content, tooltipConfig }: TooltipProps) => {
|
||||
...tooltipConfig,
|
||||
});
|
||||
|
||||
const tooltipStyle = [
|
||||
'z-50 text-sm absolute font-normal bg-gray-800 px-2 py-1 rounded border border-gray-600 shadow text-gray-100',
|
||||
];
|
||||
|
||||
if (className) {
|
||||
tooltipStyle.push(className);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{React.cloneElement(children, { ref: setTriggerRef })}
|
||||
{visible && content && (
|
||||
<div
|
||||
ref={setTooltipRef}
|
||||
{...getTooltipProps({
|
||||
className:
|
||||
'z-50 text-sm font-normal bg-gray-800 px-2 py-1 rounded border border-gray-600 shadow text-gray-100',
|
||||
})}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
{visible &&
|
||||
content &&
|
||||
ReactDOM.createPortal(
|
||||
<div
|
||||
ref={setTooltipRef}
|
||||
{...getTooltipProps({
|
||||
className: tooltipStyle.join(' '),
|
||||
})}
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -1,23 +1,39 @@
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
estimatedtime: 'Estimated {time}',
|
||||
formattedTitle: '{title}: Season {seasonNumber} Episode {episodeNumber}',
|
||||
});
|
||||
|
||||
interface DownloadBlockProps {
|
||||
downloadItem: DownloadingItem;
|
||||
is4k?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const DownloadBlock = ({ downloadItem, is4k = false }: DownloadBlockProps) => {
|
||||
const DownloadBlock = ({
|
||||
downloadItem,
|
||||
is4k = false,
|
||||
title,
|
||||
}: DownloadBlockProps) => {
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="mb-2 w-56 truncate text-sm sm:w-80 md:w-full">
|
||||
{downloadItem.title}
|
||||
{hasPermission(Permission.ADMIN)
|
||||
? downloadItem.title
|
||||
: downloadItem.episode
|
||||
? intl.formatMessage(messages.formattedTitle, {
|
||||
title,
|
||||
seasonNumber: downloadItem?.episode?.seasonNumber,
|
||||
episodeNumber: downloadItem?.episode?.episodeNumber,
|
||||
})
|
||||
: title}
|
||||
</div>
|
||||
<div className="relative mb-2 h-6 min-w-0 overflow-hidden rounded-full bg-gray-700">
|
||||
<div
|
||||
|
@@ -305,6 +305,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
<div className="media-status">
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status}
|
||||
downloadItem={data.mediaInfo?.downloadStatus}
|
||||
title={data.title}
|
||||
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||
tmdbId={data.mediaInfo?.tmdbId}
|
||||
mediaType="movie"
|
||||
@@ -324,6 +326,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
) && (
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status4k}
|
||||
downloadItem={data.mediaInfo?.downloadStatus4k}
|
||||
title={data.title}
|
||||
is4k
|
||||
inProgress={
|
||||
(data.mediaInfo?.downloadStatus4k ?? []).length > 0
|
||||
|
@@ -397,6 +397,12 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
status={
|
||||
requestData.media[requestData.is4k ? 'status4k' : 'status']
|
||||
}
|
||||
downloadItem={
|
||||
(requestData.media?.downloadStatus4k ?? []).length > 0
|
||||
? requestData.media?.downloadStatus4k
|
||||
: requestData.media?.downloadStatus
|
||||
}
|
||||
title={isMovie(title) ? title.title : title.name}
|
||||
inProgress={
|
||||
(
|
||||
requestData.media[
|
||||
|
@@ -463,6 +463,12 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
status={
|
||||
requestData.media[requestData.is4k ? 'status4k' : 'status']
|
||||
}
|
||||
downloadItem={
|
||||
requestData.media?.downloadStatus4k
|
||||
? requestData.media?.downloadStatus4k
|
||||
: requestData.media?.downloadStatus
|
||||
}
|
||||
title={isMovie(title) ? title.title : title.name}
|
||||
inProgress={
|
||||
(
|
||||
requestData.media[
|
||||
|
@@ -1,10 +1,12 @@
|
||||
import Spinner from '@app/assets/spinner.svg';
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import DownloadBlock from '@app/components/DownloadBlock';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -13,26 +15,31 @@ const messages = defineMessages({
|
||||
playonplex: 'Play on Plex',
|
||||
openinarr: 'Open in {arr}',
|
||||
managemedia: 'Manage {mediaType}',
|
||||
seasonepisodenumber: 'S{seasonNumber}E{episodeNumber}',
|
||||
});
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status?: MediaStatus;
|
||||
downloadItem?: DownloadingItem[];
|
||||
is4k?: boolean;
|
||||
inProgress?: boolean;
|
||||
plexUrl?: string;
|
||||
serviceUrl?: string;
|
||||
tmdbId?: number;
|
||||
mediaType?: 'movie' | 'tv';
|
||||
title?: string | string[];
|
||||
}
|
||||
|
||||
const StatusBadge = ({
|
||||
status,
|
||||
downloadItem = [],
|
||||
is4k = false,
|
||||
inProgress = false,
|
||||
plexUrl,
|
||||
serviceUrl,
|
||||
tmdbId,
|
||||
mediaType,
|
||||
title,
|
||||
}: StatusBadgeProps) => {
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
@@ -41,6 +48,10 @@ const StatusBadge = ({
|
||||
let mediaLink: string | undefined;
|
||||
let mediaLinkDescription: string | undefined;
|
||||
|
||||
const calculateDownloadProgress = (media: DownloadingItem) => {
|
||||
return Math.round(((media?.size - media?.sizeLeft) / media?.size) * 100);
|
||||
};
|
||||
|
||||
if (
|
||||
mediaType &&
|
||||
plexUrl &&
|
||||
@@ -85,21 +96,87 @@ const StatusBadge = ({
|
||||
}
|
||||
}
|
||||
|
||||
const tooltipContent = downloadItem ? (
|
||||
<ul>
|
||||
{downloadItem.map((status, index) => (
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<DownloadBlock
|
||||
downloadItem={status}
|
||||
title={Array.isArray(title) ? title[index] : title}
|
||||
is4k={is4k}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
mediaLinkDescription
|
||||
);
|
||||
|
||||
const badgeDownloadProgress = (
|
||||
<div
|
||||
className={`
|
||||
absolute top-0 left-0 z-10 flex h-full ${
|
||||
status === MediaStatus.PROCESSING ? 'bg-indigo-500' : 'bg-green-500'
|
||||
} transition-all duration-200 ease-in-out
|
||||
`}
|
||||
style={{
|
||||
width: `${
|
||||
downloadItem ? calculateDownloadProgress(downloadItem[0]) : 0
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
switch (status) {
|
||||
case MediaStatus.AVAILABLE:
|
||||
return (
|
||||
<Tooltip content={mediaLinkDescription}>
|
||||
<Badge badgeType="success" href={mediaLink}>
|
||||
<div className="flex items-center">
|
||||
<Tooltip
|
||||
content={inProgress && tooltipContent}
|
||||
className={`${
|
||||
inProgress && 'hidden max-h-96 w-96 overflow-scroll sm:block'
|
||||
}`}
|
||||
tooltipConfig={{ interactive: true, delayHide: 100 }}
|
||||
>
|
||||
<Badge
|
||||
badgeType="success"
|
||||
href={mediaLink}
|
||||
className={`${
|
||||
inProgress &&
|
||||
'relative !bg-gray-700 !bg-opacity-80 !px-0 hover:!bg-gray-700'
|
||||
} overflow-hidden`}
|
||||
>
|
||||
{inProgress && badgeDownloadProgress}
|
||||
<div
|
||||
className={`relative z-20 flex items-center ${
|
||||
inProgress && 'px-2'
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
is4k ? messages.status4k : messages.status,
|
||||
{
|
||||
status: intl.formatMessage(globalMessages.available),
|
||||
status: inProgress
|
||||
? intl.formatMessage(globalMessages.processing)
|
||||
: intl.formatMessage(globalMessages.available),
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
{inProgress && <Spinner className="ml-1 h-3 w-3" />}
|
||||
{inProgress && (
|
||||
<>
|
||||
{mediaType === 'tv' && (
|
||||
<span className="ml-1">
|
||||
{intl.formatMessage(messages.seasonepisodenumber, {
|
||||
seasonNumber: downloadItem[0].episode?.seasonNumber,
|
||||
episodeNumber: downloadItem[0].episode?.episodeNumber,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<Spinner className="ml-1 h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
@@ -107,20 +184,50 @@ const StatusBadge = ({
|
||||
|
||||
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||
return (
|
||||
<Tooltip content={mediaLinkDescription}>
|
||||
<Badge badgeType="success" href={mediaLink}>
|
||||
<div className="flex items-center">
|
||||
<Tooltip
|
||||
content={inProgress && tooltipContent}
|
||||
className={`${
|
||||
inProgress && 'hidden max-h-96 w-96 overflow-scroll sm:block'
|
||||
}`}
|
||||
tooltipConfig={{ interactive: true, delayHide: 100 }}
|
||||
>
|
||||
<Badge
|
||||
badgeType="success"
|
||||
href={mediaLink}
|
||||
className={`${
|
||||
inProgress &&
|
||||
'relative !bg-gray-700 !bg-opacity-80 !px-0 hover:!bg-gray-700'
|
||||
} overflow-hidden`}
|
||||
>
|
||||
{inProgress && badgeDownloadProgress}
|
||||
<div
|
||||
className={`relative z-20 flex items-center ${
|
||||
inProgress && 'px-2'
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
is4k ? messages.status4k : messages.status,
|
||||
{
|
||||
status: intl.formatMessage(
|
||||
globalMessages.partiallyavailable
|
||||
),
|
||||
status: inProgress
|
||||
? intl.formatMessage(globalMessages.processing)
|
||||
: intl.formatMessage(globalMessages.partiallyavailable),
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
{inProgress && <Spinner className="ml-1 h-3 w-3" />}
|
||||
{inProgress && (
|
||||
<>
|
||||
{mediaType === 'tv' && (
|
||||
<span className="ml-1">
|
||||
{intl.formatMessage(messages.seasonepisodenumber, {
|
||||
seasonNumber: downloadItem[0].episode?.seasonNumber,
|
||||
episodeNumber: downloadItem[0].episode?.episodeNumber,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<Spinner className="ml-1 h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
@@ -128,9 +235,27 @@ const StatusBadge = ({
|
||||
|
||||
case MediaStatus.PROCESSING:
|
||||
return (
|
||||
<Tooltip content={mediaLinkDescription}>
|
||||
<Badge badgeType="primary" href={mediaLink}>
|
||||
<div className="flex items-center">
|
||||
<Tooltip
|
||||
content={inProgress && tooltipContent}
|
||||
className={`${
|
||||
inProgress && 'hidden max-h-96 w-96 overflow-scroll sm:block'
|
||||
}`}
|
||||
tooltipConfig={{ interactive: true, delayHide: 100 }}
|
||||
>
|
||||
<Badge
|
||||
badgeType="primary"
|
||||
href={mediaLink}
|
||||
className={`${
|
||||
inProgress &&
|
||||
'relative !bg-gray-700 !bg-opacity-80 !px-0 hover:!bg-gray-700'
|
||||
} overflow-hidden`}
|
||||
>
|
||||
{inProgress && badgeDownloadProgress}
|
||||
<div
|
||||
className={`relative z-20 flex items-center ${
|
||||
inProgress && 'px-2'
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
is4k ? messages.status4k : messages.status,
|
||||
@@ -141,7 +266,19 @@ const StatusBadge = ({
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
{inProgress && <Spinner className="ml-1 h-3 w-3" />}
|
||||
{inProgress && (
|
||||
<>
|
||||
{mediaType === 'tv' && (
|
||||
<span className="ml-1">
|
||||
{intl.formatMessage(messages.seasonepisodenumber, {
|
||||
seasonNumber: downloadItem[0].episode?.seasonNumber,
|
||||
episodeNumber: downloadItem[0].episode?.episodeNumber,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<Spinner className="ml-1 h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
|
@@ -318,6 +318,8 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
<div className="media-status">
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status}
|
||||
downloadItem={data.mediaInfo?.downloadStatus}
|
||||
title={data.name}
|
||||
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||
tmdbId={data.mediaInfo?.tmdbId}
|
||||
mediaType="tv"
|
||||
@@ -337,6 +339,8 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
) && (
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status4k}
|
||||
downloadItem={data.mediaInfo?.downloadStatus4k}
|
||||
title={data.name}
|
||||
is4k
|
||||
inProgress={
|
||||
(data.mediaInfo?.downloadStatus4k ?? []).length > 0
|
||||
|
@@ -34,6 +34,7 @@
|
||||
"components.Discover.upcomingmovies": "Upcoming Movies",
|
||||
"components.Discover.upcomingtv": "Upcoming Series",
|
||||
"components.DownloadBlock.estimatedtime": "Estimated {time}",
|
||||
"components.DownloadBlock.formattedTitle": "{title}: Season {seasonNumber} Episode {episodeNumber}",
|
||||
"components.IssueDetails.IssueComment.areyousuredelete": "Are you sure you want to delete this comment?",
|
||||
"components.IssueDetails.IssueComment.delete": "Delete Comment",
|
||||
"components.IssueDetails.IssueComment.edit": "Edit Comment",
|
||||
@@ -875,6 +876,7 @@
|
||||
"components.StatusBadge.managemedia": "Manage {mediaType}",
|
||||
"components.StatusBadge.openinarr": "Open in {arr}",
|
||||
"components.StatusBadge.playonplex": "Play on Plex",
|
||||
"components.StatusBadge.seasonepisodenumber": "S{seasonNumber}E{episodeNumber}",
|
||||
"components.StatusBadge.status": "{status}",
|
||||
"components.StatusBadge.status4k": "4K {status}",
|
||||
"components.StatusChecker.appUpdated": "{applicationTitle} Updated",
|
||||
|
Reference in New Issue
Block a user