Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 78 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
@ -0,0 +1,225 @@
|
||||
import axios from 'axios';
|
||||
import { Notification } from '..';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentSlack } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
interface EmbedField {
|
||||
type: 'plain_text' | 'mrkdwn';
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface TextItem {
|
||||
type: 'plain_text' | 'mrkdwn';
|
||||
text: string;
|
||||
emoji?: boolean;
|
||||
}
|
||||
|
||||
interface Element {
|
||||
type: 'button';
|
||||
text?: TextItem;
|
||||
value: string;
|
||||
url: string;
|
||||
action_id: 'button-action';
|
||||
}
|
||||
|
||||
interface EmbedBlock {
|
||||
type: 'header' | 'actions' | 'section' | 'context';
|
||||
block_id?: 'section789';
|
||||
text?: TextItem;
|
||||
fields?: EmbedField[];
|
||||
accessory?: {
|
||||
type: 'image';
|
||||
image_url: string;
|
||||
alt_text: string;
|
||||
};
|
||||
elements?: Element[];
|
||||
}
|
||||
|
||||
interface SlackBlockEmbed {
|
||||
blocks: EmbedBlock[];
|
||||
}
|
||||
|
||||
class SlackAgent
|
||||
extends BaseAgent<NotificationAgentSlack>
|
||||
implements NotificationAgent {
|
||||
protected getSettings(): NotificationAgentSlack {
|
||||
if (this.settings) {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
return settings.notifications.agents.slack;
|
||||
}
|
||||
|
||||
public buildEmbed(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): SlackBlockEmbed {
|
||||
const settings = getSettings();
|
||||
let header = 'Overseerr';
|
||||
let actionUrl: string | undefined;
|
||||
|
||||
const fields: EmbedField[] = [];
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
header = 'New Request';
|
||||
fields.push(
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nPending Approval',
|
||||
}
|
||||
);
|
||||
if (settings.main.applicationUrl) {
|
||||
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
|
||||
}
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
header = 'Request Approved';
|
||||
fields.push(
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nProcessing Request',
|
||||
}
|
||||
);
|
||||
if (settings.main.applicationUrl) {
|
||||
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
|
||||
}
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
header = 'Now available!';
|
||||
fields.push(
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nAvailable',
|
||||
}
|
||||
);
|
||||
|
||||
if (settings.main.applicationUrl) {
|
||||
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const blocks: EmbedBlock[] = [
|
||||
{
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: header,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `*${payload.subject}*`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (payload.message) {
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: payload.message,
|
||||
},
|
||||
accessory: payload.image
|
||||
? {
|
||||
type: 'image',
|
||||
image_url: payload.image,
|
||||
alt_text: payload.subject,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (fields.length > 0) {
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
fields: [
|
||||
...fields,
|
||||
...(payload.extra ?? []).map(
|
||||
(extra): EmbedField => ({
|
||||
type: 'mrkdwn',
|
||||
text: `*${extra.name}*\n${extra.value}`,
|
||||
})
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (actionUrl) {
|
||||
blocks.push({
|
||||
type: 'actions',
|
||||
elements: [
|
||||
{
|
||||
action_id: 'button-action',
|
||||
type: 'button',
|
||||
url: actionUrl,
|
||||
value: 'open_overseerr',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: 'Open Overseerr',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
blocks,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Add checking for type here once we add notification type filters for agents
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public shouldSend(_type: Notification): boolean {
|
||||
if (this.getSettings().enabled && this.getSettings().options.webhookUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async send(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
logger.debug('Sending slack notification', { label: 'Notifications' });
|
||||
try {
|
||||
const webhookUrl = this.getSettings().options.webhookUrl;
|
||||
|
||||
if (!webhookUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await axios.post(webhookUrl, this.buildEmbed(type, payload));
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending Slack notification', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SlackAgent;
|
@ -0,0 +1,29 @@
|
||||
import { TmdbCollection } from '../api/themoviedb';
|
||||
import Media from '../entity/Media';
|
||||
import { mapMovieResult, MovieResult } from './Search';
|
||||
|
||||
export interface Collection {
|
||||
id: number;
|
||||
name: string;
|
||||
overview?: string;
|
||||
posterPath?: string;
|
||||
backdropPath?: string;
|
||||
parts: MovieResult[];
|
||||
}
|
||||
|
||||
export const mapCollection = (
|
||||
collection: TmdbCollection,
|
||||
media: Media[]
|
||||
): Collection => ({
|
||||
id: collection.id,
|
||||
name: collection.name,
|
||||
overview: collection.overview,
|
||||
posterPath: collection.poster_path,
|
||||
backdropPath: collection.backdrop_path,
|
||||
parts: collection.parts.map((part) =>
|
||||
mapMovieResult(
|
||||
part,
|
||||
media?.find((req) => req.tmdbId === part.id)
|
||||
)
|
||||
),
|
||||
});
|
@ -0,0 +1,27 @@
|
||||
import { Router } from 'express';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import Media from '../entity/Media';
|
||||
import { mapCollection } from '../models/Collection';
|
||||
|
||||
const collectionRoutes = Router();
|
||||
|
||||
collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const collection = await tmdb.getCollection({
|
||||
collectionId: Number(req.params.id),
|
||||
language: req.query.language as string,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
collection.parts.map((part) => part.id)
|
||||
);
|
||||
|
||||
return res.status(200).json(mapCollection(collection, media));
|
||||
} catch (e) {
|
||||
return next({ status: 404, message: 'Collection does not exist' });
|
||||
}
|
||||
});
|
||||
|
||||
export default collectionRoutes;
|
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@ -0,0 +1,267 @@
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import type { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import type { Collection } from '../../../server/models/Collection';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Error from '../../pages/_error';
|
||||
import Badge from '../Common/Badge';
|
||||
import Button from '../Common/Button';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import Modal from '../Common/Modal';
|
||||
import Slider from '../Slider';
|
||||
import TitleCard from '../TitleCard';
|
||||
import Transition from '../Transition';
|
||||
|
||||
const messages = defineMessages({
|
||||
overviewunavailable: 'Overview unavailable',
|
||||
overview: 'Overview',
|
||||
movies: 'Movies',
|
||||
numberofmovies: 'Number of Movies: {count}',
|
||||
requesting: 'Requesting…',
|
||||
request: 'Request',
|
||||
requestcollection: 'Request Collection',
|
||||
requestswillbecreated:
|
||||
'The following titles will have requests created for them:',
|
||||
requestSuccess: '<strong>{title}</strong> successfully requested!',
|
||||
});
|
||||
|
||||
interface CollectionDetailsProps {
|
||||
collection?: Collection;
|
||||
}
|
||||
|
||||
const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
collection,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const { addToast } = useToasts();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const [requestModal, setRequestModal] = useState(false);
|
||||
const [isRequesting, setRequesting] = useState(false);
|
||||
const { data, error, revalidate } = useSWR<Collection>(
|
||||
`/api/v1/collection/${router.query.collectionId}?language=${locale}`,
|
||||
{
|
||||
initialData: collection,
|
||||
revalidateOnMount: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
const requestableParts = data.parts.filter(
|
||||
(part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN
|
||||
);
|
||||
|
||||
const requestBundle = async () => {
|
||||
try {
|
||||
setRequesting(true);
|
||||
await Promise.all(
|
||||
requestableParts.map(async (part) => {
|
||||
await axios.post<MediaRequest>('/api/v1/request', {
|
||||
mediaId: part.id,
|
||||
mediaType: 'movie',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
addToast(
|
||||
<span>
|
||||
{intl.formatMessage(messages.requestSuccess, {
|
||||
title: data?.name,
|
||||
strong: function strong(msg) {
|
||||
return <strong>{msg}</strong>;
|
||||
},
|
||||
})}
|
||||
</span>,
|
||||
{ appearance: 'success', autoDismiss: true }
|
||||
);
|
||||
} catch (e) {
|
||||
addToast('Something went wrong requesting the collection.', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
setRequestModal(false);
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover sm:px-8 "
|
||||
style={{
|
||||
height: 493,
|
||||
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
|
||||
}}
|
||||
>
|
||||
<Head>
|
||||
<title>{data.name} - Overseerr</title>
|
||||
</Head>
|
||||
<Transition
|
||||
enter="opacity-0 transition duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="opacity-100 transition duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={requestModal}
|
||||
>
|
||||
<Modal
|
||||
onOk={() => requestBundle()}
|
||||
okText={
|
||||
isRequesting
|
||||
? intl.formatMessage(messages.requesting)
|
||||
: intl.formatMessage(messages.request)
|
||||
}
|
||||
okDisabled={isRequesting}
|
||||
okButtonType="primary"
|
||||
onCancel={() => setRequestModal(false)}
|
||||
title={intl.formatMessage(messages.requestcollection)}
|
||||
iconSvg={
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<p>{intl.formatMessage(messages.requestswillbecreated)}</p>
|
||||
<ul className="py-4 pl-8 list-disc">
|
||||
{data.parts
|
||||
.filter(
|
||||
(part) =>
|
||||
!part.mediaInfo ||
|
||||
part.mediaInfo?.status === MediaStatus.UNKNOWN
|
||||
)
|
||||
.map((part) => (
|
||||
<li key={`request-part-${part.id}`}>{part.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Modal>
|
||||
</Transition>
|
||||
<div className="flex flex-col items-center pt-4 md:flex-row md:items-end">
|
||||
<div className="flex-shrink-0 md:mr-4">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
|
||||
alt=""
|
||||
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-52"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col mt-4 text-center text-white md:mr-4 md:mt-0 md:text-left">
|
||||
<div className="mb-2">
|
||||
{data.parts.every(
|
||||
(part) => part.mediaInfo?.status === MediaStatus.AVAILABLE
|
||||
) && (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(globalMessages.available)}
|
||||
</Badge>
|
||||
)}
|
||||
{!data.parts.every(
|
||||
(part) => part.mediaInfo?.status === MediaStatus.AVAILABLE
|
||||
) &&
|
||||
data.parts.some(
|
||||
(part) => part.mediaInfo?.status === MediaStatus.AVAILABLE
|
||||
) && (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(globalMessages.partiallyavailable)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-4xl">{data.name}</h1>
|
||||
<span className="mt-1 text-xs md:text-base md:mt-0">
|
||||
{intl.formatMessage(messages.numberofmovies, {
|
||||
count: data.parts.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-end flex-1 mt-4 md:mt-0">
|
||||
{data.parts.some(
|
||||
(part) =>
|
||||
!part.mediaInfo || part.mediaInfo?.status === MediaStatus.UNKNOWN
|
||||
) && (
|
||||
<Button buttonType="primary" onClick={() => setRequestModal(true)}>
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
{intl.formatMessage(messages.requestcollection)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
|
||||
<div className="flex-1 md:mr-8">
|
||||
<h2 className="text-xl md:text-2xl">
|
||||
{intl.formatMessage(messages.overview)}
|
||||
</h2>
|
||||
<p className="pt-2 text-sm md:text-base">
|
||||
{data.overview
|
||||
? data.overview
|
||||
: intl.formatMessage(messages.overviewunavailable)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-flex items-center text-xl leading-7 text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>{intl.formatMessage(messages.movies)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="collection-movies"
|
||||
isLoading={false}
|
||||
isEmpty={data.parts.length === 0}
|
||||
items={data.parts.map((title) => (
|
||||
<TitleCard
|
||||
key={`collection-movie-${title.id}`}
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
userScore={title.voteAverage}
|
||||
year={title.releaseDate}
|
||||
mediaType={title.mediaType}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
<div className="pb-8" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionDetails;
|
@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { hasPermission } from '../../../server/lib/permissions';
|
||||
import { Permission, User } from '../../hooks/useUser';
|
||||
|
||||
export interface PermissionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
permission: Permission;
|
||||
children?: PermissionItem[];
|
||||
}
|
||||
|
||||
interface PermissionOptionProps {
|
||||
option: PermissionItem;
|
||||
currentPermission: number;
|
||||
user?: User;
|
||||
parent?: PermissionItem;
|
||||
onUpdate: (newPermissions: number) => void;
|
||||
}
|
||||
|
||||
const PermissionOption: React.FC<PermissionOptionProps> = ({
|
||||
option,
|
||||
currentPermission,
|
||||
onUpdate,
|
||||
user,
|
||||
parent,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`relative flex items-start first:mt-0 mt-4 ${
|
||||
(option.permission !== Permission.ADMIN &&
|
||||
hasPermission(Permission.ADMIN, currentPermission)) ||
|
||||
(!!parent?.permission &&
|
||||
hasPermission(parent.permission, currentPermission)) ||
|
||||
(user && user.id !== 1 && option.permission === Permission.ADMIN) ||
|
||||
(user &&
|
||||
!hasPermission(Permission.MANAGE_SETTINGS, user.permissions) &&
|
||||
option.permission === Permission.MANAGE_SETTINGS)
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id={option.id}
|
||||
name="permissions"
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
|
||||
disabled={
|
||||
(option.permission !== Permission.ADMIN &&
|
||||
hasPermission(Permission.ADMIN, currentPermission)) ||
|
||||
(!!parent?.permission &&
|
||||
hasPermission(parent.permission, currentPermission)) ||
|
||||
(user &&
|
||||
user.id !== 1 &&
|
||||
option.permission === Permission.ADMIN) ||
|
||||
(user &&
|
||||
!hasPermission(Permission.MANAGE_SETTINGS, user.permissions) &&
|
||||
option.permission === Permission.MANAGE_SETTINGS)
|
||||
}
|
||||
onClick={() => {
|
||||
onUpdate(
|
||||
hasPermission(option.permission, currentPermission)
|
||||
? currentPermission - option.permission
|
||||
: currentPermission + option.permission
|
||||
);
|
||||
}}
|
||||
checked={
|
||||
hasPermission(option.permission, currentPermission) ||
|
||||
(!!parent?.permission &&
|
||||
hasPermission(parent.permission, currentPermission))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-5">
|
||||
<label htmlFor={option.id} className="font-medium">
|
||||
{option.name}
|
||||
</label>
|
||||
<p className="text-gray-500">{option.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{(option.children ?? []).map((child) => (
|
||||
<div key={`permission-child-${child.id}`} className="pl-6 mt-4">
|
||||
<PermissionOption
|
||||
option={child}
|
||||
currentPermission={currentPermission}
|
||||
onUpdate={(newPermission) => onUpdate(newPermission)}
|
||||
parent={option}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionOption;
|
@ -0,0 +1,189 @@
|
||||
import React from 'react';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../../../Common/LoadingSpinner';
|
||||
import Button from '../../../Common/Button';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import axios from 'axios';
|
||||
import * as Yup from 'yup';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import Alert from '../../../Common/Alert';
|
||||
|
||||
const messages = defineMessages({
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving...',
|
||||
agentenabled: 'Agent Enabled',
|
||||
webhookUrl: 'Webhook URL',
|
||||
validationWebhookUrlRequired: 'You must provide a webhook URL',
|
||||
webhookUrlPlaceholder: 'Webhook URL',
|
||||
slacksettingssaved: 'Slack notification settings saved!',
|
||||
slacksettingsfailed: 'Slack notification settings failed to save.',
|
||||
testsent: 'Test notification sent!',
|
||||
test: 'Test',
|
||||
settingupslack: 'Setting up Slack Notifications',
|
||||
settingupslackDescription:
|
||||
'To use Slack notifications, you will need to create an <WebhookLink>Incoming Webhook</WebhookLink> integration and use the provided webhook URL below.',
|
||||
});
|
||||
|
||||
const NotificationsSlack: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/slack'
|
||||
);
|
||||
|
||||
const NotificationsSlackSchema = Yup.object().shape({
|
||||
webhookUrl: Yup.string().required(
|
||||
intl.formatMessage(messages.validationWebhookUrlRequired)
|
||||
),
|
||||
});
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mb-">
|
||||
<Alert title={intl.formatMessage(messages.settingupslack)} type="info">
|
||||
{intl.formatMessage(messages.settingupslackDescription, {
|
||||
WebhookLink: function WebhookLink(msg) {
|
||||
return (
|
||||
<a
|
||||
href="https://my.slack.com/services/new/incoming-webhook/"
|
||||
className="text-indigo-100 hover:text-white hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
})}
|
||||
</Alert>
|
||||
</p>
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: data.enabled,
|
||||
types: data.types,
|
||||
webhookUrl: data.options.webhookUrl,
|
||||
}}
|
||||
validationSchema={NotificationsSlackSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/notifications/slack', {
|
||||
enabled: values.enabled,
|
||||
types: values.types,
|
||||
options: {
|
||||
webhookUrl: values.webhookUrl,
|
||||
},
|
||||
});
|
||||
addToast(intl.formatMessage(messages.slacksettingssaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.slacksettingsfailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, values, isValid }) => {
|
||||
const testSettings = async () => {
|
||||
await axios.post('/api/v1/settings/notifications/slack/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {
|
||||
webhookUrl: values.webhookUrl,
|
||||
},
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.testsent), {
|
||||
appearance: 'info',
|
||||
autoDismiss: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
|
||||
<label
|
||||
htmlFor="isDefault"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
{intl.formatMessage(messages.agentenabled)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
name="enabled"
|
||||
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
{intl.formatMessage(messages.webhookUrl)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="webhookUrl"
|
||||
name="webhookUrl"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.webhookUrlPlaceholder
|
||||
)}
|
||||
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.webhookUrl && touched.webhookUrl && (
|
||||
<div className="mt-2 text-red-500">{errors.webhookUrl}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-5 mt-8 border-t border-gray-700">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="warning"
|
||||
disabled={isSubmitting || !isValid}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
testSettings();
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.test)}
|
||||
</Button>
|
||||
</span>
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsSlack;
|
@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { GetServerSideProps, NextPage } from 'next';
|
||||
import type { Collection } from '../../../../server/models/Collection';
|
||||
import axios from 'axios';
|
||||
import { parseCookies } from 'nookies';
|
||||
import CollectionDetails from '../../../components/CollectionDetails';
|
||||
|
||||
interface CollectionPageProps {
|
||||
collection?: Collection;
|
||||
}
|
||||
|
||||
const CollectionPage: NextPage<CollectionPageProps> = ({ collection }) => {
|
||||
return <CollectionDetails collection={collection} />;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<CollectionPageProps> = async (
|
||||
ctx
|
||||
) => {
|
||||
const cookies = parseCookies(ctx);
|
||||
const response = await axios.get<Collection>(
|
||||
`http://localhost:${process.env.PORT || 5055}/api/v1/collection/${
|
||||
ctx.query.collectionId
|
||||
}${cookies.locale ? `?language=${cookies.locale}` : ''}`,
|
||||
{
|
||||
headers: ctx.req?.headers?.cookie
|
||||
? { cookie: ctx.req.headers.cookie }
|
||||
: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
props: {
|
||||
collection: response.data,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default CollectionPage;
|
@ -0,0 +1,17 @@
|
||||
import { NextPage } from 'next';
|
||||
import React from 'react';
|
||||
import NotificationsSlack from '../../../components/Settings/Notifications/NotificationsSlack';
|
||||
import SettingsLayout from '../../../components/Settings/SettingsLayout';
|
||||
import SettingsNotifications from '../../../components/Settings/SettingsNotifications';
|
||||
|
||||
const NotificationsSlackPage: NextPage = () => {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<SettingsNotifications>
|
||||
<NotificationsSlack />
|
||||
</SettingsNotifications>
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsSlackPage;
|