🔀 Merge branch 'dev' into feature/dashdot-consistency-changes
This commit is contained in:
26
src/components/AppAvatar.tsx
Normal file
26
src/components/AppAvatar.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Avatar, DefaultMantineColor, useMantineTheme } from '@mantine/core';
|
||||
|
||||
export const AppAvatar = ({
|
||||
iconUrl,
|
||||
color,
|
||||
}: {
|
||||
iconUrl: string;
|
||||
color?: DefaultMantineColor | undefined;
|
||||
}) => {
|
||||
const { colors, colorScheme } = useMantineTheme();
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
src={iconUrl}
|
||||
bg={colorScheme === 'dark' ? colors.gray[8] : colors.gray[2]}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
p={4}
|
||||
styles={{
|
||||
root: {
|
||||
borderColor: color !== undefined ? colors[color] : undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -75,6 +75,16 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png',
|
||||
label: 'Readarr',
|
||||
},
|
||||
{
|
||||
value: 'jellyfin',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png',
|
||||
label: 'Jellyfin',
|
||||
},
|
||||
{
|
||||
value: 'plex',
|
||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png',
|
||||
label: 'Plex',
|
||||
},
|
||||
].filter((x) => Object.keys(integrationFieldProperties).includes(x.value));
|
||||
|
||||
const getNewProperties = (value: string | null): AppIntegrationPropertyType[] => {
|
||||
|
||||
17
src/hooks/widgets/media-servers/useGetMediaServers.tsx
Normal file
17
src/hooks/widgets/media-servers/useGetMediaServers.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { MediaServersResponseType } from '../../../types/api/media-server/response';
|
||||
|
||||
interface GetMediaServersParams {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const useGetMediaServers = ({ enabled }: GetMediaServersParams) =>
|
||||
useQuery({
|
||||
queryKey: ['media-servers'],
|
||||
queryFn: async (): Promise<MediaServersResponseType> => {
|
||||
const response = await fetch('/api/modules/media-server');
|
||||
return response.json();
|
||||
},
|
||||
enabled,
|
||||
refetchInterval: 10 * 1000,
|
||||
});
|
||||
10
src/hooks/widgets/rss/useGetRssFeed.tsx
Normal file
10
src/hooks/widgets/rss/useGetRssFeed.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const useGetRssFeed = (feedUrl: string) =>
|
||||
useQuery({
|
||||
queryKey: ['rss-feed', feedUrl],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/modules/rss');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
227
src/pages/api/modules/media-server/index.ts
Normal file
227
src/pages/api/modules/media-server/index.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { Jellyfin } from '@jellyfin/sdk';
|
||||
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
|
||||
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
|
||||
|
||||
import Consola from 'consola';
|
||||
|
||||
import { getCookie } from 'cookies-next';
|
||||
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { BaseItemKind, ProgramAudio } from '@jellyfin/sdk/lib/generated-client/models';
|
||||
import { getConfig } from '../../../../tools/config/getConfig';
|
||||
import { PlexClient } from '../../../../tools/server/sdk/plex/plexClient';
|
||||
import { GenericMediaServer } from '../../../../types/api/media-server/media-server';
|
||||
import { MediaServersResponseType } from '../../../../types/api/media-server/response';
|
||||
import {
|
||||
GenericCurrentlyPlaying,
|
||||
GenericSessionInfo,
|
||||
} from '../../../../types/api/media-server/session-info';
|
||||
import { ConfigAppType } from '../../../../types/app';
|
||||
|
||||
const jellyfin = new Jellyfin({
|
||||
clientInfo: {
|
||||
name: 'Homarr',
|
||||
version: '0.0.1',
|
||||
},
|
||||
deviceInfo: {
|
||||
name: 'Homarr Jellyfin Widget',
|
||||
id: 'homarr-jellyfin-widget',
|
||||
},
|
||||
});
|
||||
|
||||
const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
||||
const configName = getCookie('config-name', { req: request });
|
||||
const config = getConfig(configName?.toString() ?? 'default');
|
||||
|
||||
const apps = config.apps.filter((app) =>
|
||||
['jellyfin', 'plex'].includes(app.integration?.type ?? '')
|
||||
);
|
||||
|
||||
const servers = await Promise.all(
|
||||
apps.map(async (app): Promise<GenericMediaServer | undefined> => {
|
||||
try {
|
||||
return await handleServer(app);
|
||||
} catch (error) {
|
||||
Consola.error(
|
||||
`failed to communicate with media server '${app.name}' (${app.id}): ${error}`
|
||||
);
|
||||
return {
|
||||
serverAddress: app.url,
|
||||
sessions: [],
|
||||
success: false,
|
||||
version: undefined,
|
||||
type: undefined,
|
||||
appId: app.id,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return response.status(200).json({
|
||||
servers: servers.filter((server) => server !== undefined),
|
||||
} as MediaServersResponseType);
|
||||
};
|
||||
|
||||
const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | undefined> => {
|
||||
switch (app.integration?.type) {
|
||||
case 'jellyfin': {
|
||||
const username = app.integration.properties.find((x) => x.field === 'username');
|
||||
|
||||
if (!username || !username.value) {
|
||||
return {
|
||||
appId: app.id,
|
||||
serverAddress: app.url,
|
||||
sessions: [],
|
||||
type: 'jellyfin',
|
||||
version: undefined,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const password = app.integration.properties.find((x) => x.field === 'password');
|
||||
|
||||
if (!password || !password.value) {
|
||||
return {
|
||||
appId: app.id,
|
||||
serverAddress: app.url,
|
||||
sessions: [],
|
||||
type: 'jellyfin',
|
||||
version: undefined,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const api = jellyfin.createApi(app.url);
|
||||
const infoApi = await getSystemApi(api).getPublicSystemInfo();
|
||||
await api.authenticateUserByName(username.value, password.value);
|
||||
const sessionApi = await getSessionApi(api);
|
||||
const sessions = await sessionApi.getSessions();
|
||||
return {
|
||||
type: 'jellyfin',
|
||||
appId: app.id,
|
||||
serverAddress: app.url,
|
||||
version: infoApi.data.Version ?? undefined,
|
||||
sessions: sessions.data.map(
|
||||
(session): GenericSessionInfo => ({
|
||||
id: session.Id ?? '?',
|
||||
username: session.UserName ?? undefined,
|
||||
sessionName: `${session.Client} (${session.DeviceName})`,
|
||||
supportsMediaControl: session.SupportsMediaControl ?? false,
|
||||
currentlyPlaying: session.NowPlayingItem
|
||||
? {
|
||||
name: session.NowPlayingItem.Name as string,
|
||||
seasonName: session.NowPlayingItem.SeasonName as string,
|
||||
albumName: session.NowPlayingItem.Album as string,
|
||||
episodeCount: session.NowPlayingItem.EpisodeCount ?? undefined,
|
||||
metadata: {
|
||||
video:
|
||||
session.NowPlayingItem &&
|
||||
session.NowPlayingItem.Width &&
|
||||
session.NowPlayingItem.Height
|
||||
? {
|
||||
videoCodec: undefined,
|
||||
width: session.NowPlayingItem.Width ?? undefined,
|
||||
height: session.NowPlayingItem.Height ?? undefined,
|
||||
bitrate: undefined,
|
||||
videoFrameRate: session.TranscodingInfo?.Framerate
|
||||
? String(session.TranscodingInfo?.Framerate)
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
audio: session.TranscodingInfo
|
||||
? {
|
||||
audioChannels: session.TranscodingInfo.AudioChannels ?? undefined,
|
||||
audioCodec: session.TranscodingInfo.AudioCodec ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
transcoding: session.TranscodingInfo
|
||||
? {
|
||||
audioChannels: session.TranscodingInfo.AudioChannels ?? -1,
|
||||
audioCodec: session.TranscodingInfo.AudioCodec ?? undefined,
|
||||
container: session.TranscodingInfo.Container ?? undefined,
|
||||
width: session.TranscodingInfo.Width ?? undefined,
|
||||
height: session.TranscodingInfo.Height ?? undefined,
|
||||
videoCodec: session.TranscodingInfo?.VideoCodec ?? undefined,
|
||||
audioDecision: undefined,
|
||||
context: undefined,
|
||||
duration: undefined,
|
||||
error: undefined,
|
||||
sourceAudioCodec: undefined,
|
||||
sourceVideoCodec: undefined,
|
||||
timeStamp: undefined,
|
||||
transcodeHwRequested: undefined,
|
||||
videoDecision: undefined,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
type: convertJellyfinType(session.NowPlayingItem.Type),
|
||||
}
|
||||
: undefined,
|
||||
userProfilePicture: undefined,
|
||||
})
|
||||
),
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
case 'plex': {
|
||||
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey');
|
||||
|
||||
if (!apiKey || !apiKey.value) {
|
||||
return {
|
||||
serverAddress: app.url,
|
||||
sessions: [],
|
||||
type: 'plex',
|
||||
appId: app.id,
|
||||
version: undefined,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const plexClient = new PlexClient(app.url, apiKey.value);
|
||||
const sessions = await plexClient.getSessions();
|
||||
return {
|
||||
serverAddress: app.url,
|
||||
sessions,
|
||||
type: 'plex',
|
||||
version: undefined,
|
||||
appId: app.id,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
Consola.warn(
|
||||
`media-server api entered a fallback case. This should normally not happen and must be reported. Cause: '${app.name}' (${app.id})`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const convertJellyfinType = (kind: BaseItemKind | undefined): GenericCurrentlyPlaying['type'] => {
|
||||
switch (kind) {
|
||||
case BaseItemKind.Audio:
|
||||
case BaseItemKind.MusicVideo:
|
||||
return 'audio';
|
||||
case BaseItemKind.Episode:
|
||||
case BaseItemKind.Video:
|
||||
return 'video';
|
||||
case BaseItemKind.Movie:
|
||||
return 'movie';
|
||||
case BaseItemKind.TvChannel:
|
||||
case BaseItemKind.TvProgram:
|
||||
case BaseItemKind.LiveTvChannel:
|
||||
case BaseItemKind.LiveTvProgram:
|
||||
return 'tv';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export default async (request: NextApiRequest, response: NextApiResponse) => {
|
||||
if (request.method === 'GET') {
|
||||
return Get(request, response);
|
||||
}
|
||||
|
||||
return response.status(405);
|
||||
};
|
||||
93
src/pages/api/modules/rss/index.ts
Normal file
93
src/pages/api/modules/rss/index.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import Consola from 'consola';
|
||||
|
||||
import { getCookie } from 'cookies-next';
|
||||
|
||||
import { decode } from 'html-entities';
|
||||
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import Parser from 'rss-parser';
|
||||
|
||||
import { getConfig } from '../../../../tools/config/getConfig';
|
||||
import { Stopwatch } from '../../../../tools/shared/stopwatch';
|
||||
import { IRssWidget } from '../../../../widgets/rss/RssWidgetTile';
|
||||
|
||||
type CustomItem = {
|
||||
'media:content': string;
|
||||
enclosure: {
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
const parser: Parser<any, CustomItem> = new Parser({
|
||||
customFields: {
|
||||
item: ['media:content', 'enclosure'],
|
||||
},
|
||||
});
|
||||
|
||||
export const Get = async (request: NextApiRequest, response: NextApiResponse) => {
|
||||
const configName = getCookie('config-name', { req: request });
|
||||
const config = getConfig(configName?.toString() ?? 'default');
|
||||
|
||||
const rssWidget = config.widgets.find((x) => x.id === 'rss') as IRssWidget | undefined;
|
||||
|
||||
if (
|
||||
!rssWidget ||
|
||||
!rssWidget.properties.rssFeedUrl ||
|
||||
rssWidget.properties.rssFeedUrl.length < 1
|
||||
) {
|
||||
response.status(400).json({ message: 'required widget does not exist' });
|
||||
return;
|
||||
}
|
||||
|
||||
Consola.info('Requesting RSS feed...');
|
||||
const stopWatch = new Stopwatch();
|
||||
const feed = await parser.parseURL(rssWidget.properties.rssFeedUrl);
|
||||
Consola.info(`Retrieved RSS feed after ${stopWatch.getEllapsedMilliseconds()} milliseconds`);
|
||||
|
||||
const orderedFeed = {
|
||||
...feed,
|
||||
items: feed.items
|
||||
.map((item: { title: any; content: any }) => ({
|
||||
...item,
|
||||
title: item.title ? decode(item.title) : undefined,
|
||||
content: decode(item.content),
|
||||
enclosure: createEnclosure(item),
|
||||
}))
|
||||
.sort((a: { pubDate: number }, b: { pubDate: number }) => {
|
||||
if (!a.pubDate || !b.pubDate) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return a.pubDate - b.pubDate;
|
||||
})
|
||||
.slice(0, 20),
|
||||
};
|
||||
|
||||
response.status(200).json({
|
||||
feed: orderedFeed,
|
||||
success: orderedFeed?.items !== undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const createEnclosure = (item: any) => {
|
||||
if (item.enclosure) {
|
||||
return item.enclosure;
|
||||
}
|
||||
|
||||
if (item['media:content']) {
|
||||
return {
|
||||
url: item['media:content'].$.url,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export default async (request: NextApiRequest, response: NextApiResponse) => {
|
||||
if (request.method === 'GET') {
|
||||
return Get(request, response);
|
||||
}
|
||||
|
||||
return response.status(405);
|
||||
};
|
||||
@@ -92,3 +92,8 @@
|
||||
height: 0px;
|
||||
min-height: 0px !important;
|
||||
}
|
||||
|
||||
.scroll-area-w100 .mantine-ScrollArea-viewport > div:nth-of-type(1) {
|
||||
width: 100%;
|
||||
display: inherit !important;
|
||||
}
|
||||
108
src/tools/server/sdk/plex/plexClient.ts
Normal file
108
src/tools/server/sdk/plex/plexClient.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Element, xml2js } from 'xml-js';
|
||||
|
||||
import {
|
||||
GenericCurrentlyPlaying,
|
||||
GenericSessionInfo,
|
||||
} from '../../../../types/api/media-server/session-info';
|
||||
|
||||
export class PlexClient {
|
||||
constructor(private readonly apiAddress: string, private readonly token: string) {}
|
||||
|
||||
async getSessions(): Promise<GenericSessionInfo[]> {
|
||||
const response = await fetch(`${this.apiAddress}/status/sessions?X-Plex-Token=${this.token}`);
|
||||
const body = await response.text();
|
||||
|
||||
// convert xml response to objects, as there is no JSON api
|
||||
const data = xml2js(body);
|
||||
|
||||
// TODO: Investigate when there are no media containers
|
||||
const mediaContainer = data.elements[0] as Element;
|
||||
|
||||
// no sessions are open or available
|
||||
if (!mediaContainer.elements?.some((_) => true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const videoElements = mediaContainer.elements as Element[];
|
||||
|
||||
const videos = videoElements
|
||||
.map((videoElement): GenericSessionInfo | undefined => {
|
||||
// extract the elements from the children
|
||||
const userElement = this.findElement('User', videoElement.elements);
|
||||
const playerElement = this.findElement('Player', videoElement.elements);
|
||||
const mediaElement = this.findElement('Media', videoElement.elements);
|
||||
const sessionElement = this.findElement('Session', videoElement.elements);
|
||||
|
||||
if (!userElement || !playerElement || !mediaElement || !sessionElement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { videoCodec, videoFrameRate, audioCodec, audioChannels, height, width, bitrate } =
|
||||
mediaElement;
|
||||
|
||||
const transcodingElement = this.findElement('TranscodeSession', videoElement.elements);
|
||||
|
||||
return {
|
||||
id: sessionElement.id as string,
|
||||
username: userElement.title as string,
|
||||
userProfilePicture: userElement.thumb as string,
|
||||
sessionName: `${playerElement.product} (${playerElement.title})`,
|
||||
currentlyPlaying: {
|
||||
name: videoElement.attributes?.title as string,
|
||||
type: this.getCurrentlyPlayingType(videoElement.attributes?.type as string),
|
||||
metadata: {
|
||||
video: {
|
||||
bitrate,
|
||||
height,
|
||||
videoCodec,
|
||||
videoFrameRate,
|
||||
width,
|
||||
},
|
||||
audio: {
|
||||
audioChannels,
|
||||
audioCodec,
|
||||
},
|
||||
transcoding:
|
||||
transcodingElement === undefined
|
||||
? undefined
|
||||
: {
|
||||
audioChannels: transcodingElement.audioChannels,
|
||||
audioCodec: transcodingElement.audioCodec,
|
||||
audioDecision: transcodingElement.audioDecision,
|
||||
container: transcodingElement.container,
|
||||
context: transcodingElement.context,
|
||||
duration: transcodingElement.duration,
|
||||
error: transcodingElement.error === 1,
|
||||
height: transcodingElement.height,
|
||||
sourceAudioCodec: transcodingElement.sourceAudioCodec,
|
||||
sourceVideoCodec: transcodingElement.sourceVideoCodec,
|
||||
timeStamp: transcodingElement.timeStamp,
|
||||
transcodeHwRequested: transcodingElement.transcodeHwRequested === 1,
|
||||
videoCodec: transcodingElement.videoCodec,
|
||||
videoDecision: transcodingElement.videoDecision,
|
||||
width: transcodingElement.width,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as GenericSessionInfo;
|
||||
})
|
||||
.filter((x) => x !== undefined) as GenericSessionInfo[];
|
||||
|
||||
return videos;
|
||||
}
|
||||
|
||||
private findElement(name: string, elements: Element[] | undefined) {
|
||||
return elements?.find((x) => x.name === name)?.attributes;
|
||||
}
|
||||
|
||||
private getCurrentlyPlayingType(type: string): GenericCurrentlyPlaying['type'] {
|
||||
switch (type) {
|
||||
case 'movie':
|
||||
return 'movie';
|
||||
case 'episode':
|
||||
return 'video';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,12 @@ export const dashboardNamespaces = [
|
||||
'modules/torrents-status',
|
||||
'modules/weather',
|
||||
'modules/ping',
|
||||
'modules/iframe',
|
||||
'modules/rss',
|
||||
'modules/docker',
|
||||
'modules/dashdot',
|
||||
'modules/overseerr',
|
||||
'modules/media-server',
|
||||
'modules/common-media-cards',
|
||||
'modules/video-stream',
|
||||
];
|
||||
|
||||
11
src/tools/shared/stopwatch.ts
Normal file
11
src/tools/shared/stopwatch.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export class Stopwatch {
|
||||
private startTime: Date;
|
||||
|
||||
constructor() {
|
||||
this.startTime = new Date();
|
||||
}
|
||||
|
||||
getEllapsedMilliseconds() {
|
||||
return new Date().getTime() - this.startTime.getTime();
|
||||
}
|
||||
}
|
||||
34
src/types/api/media-server/media-server.ts
Normal file
34
src/types/api/media-server/media-server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { GenericSessionInfo } from './session-info';
|
||||
|
||||
export type GenericMediaServer = {
|
||||
/**
|
||||
* The type of the media server.
|
||||
* Undefined indicates, that the type is either unsupported or recognizing went wrong
|
||||
*/
|
||||
type: 'jellyfin' | 'plex' | undefined;
|
||||
|
||||
/**
|
||||
* The address of the server
|
||||
*/
|
||||
serverAddress: string;
|
||||
|
||||
/**
|
||||
* The current version of the server
|
||||
*/
|
||||
version: string | undefined;
|
||||
|
||||
/**
|
||||
* The active sessions on the server
|
||||
*/
|
||||
sessions: GenericSessionInfo[];
|
||||
|
||||
/**
|
||||
* The app id of the used app
|
||||
*/
|
||||
appId: string;
|
||||
|
||||
/**
|
||||
* Indicates, wether the communication was successfull or not
|
||||
*/
|
||||
success: boolean;
|
||||
};
|
||||
5
src/types/api/media-server/response.ts
Normal file
5
src/types/api/media-server/response.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { GenericMediaServer } from './media-server';
|
||||
|
||||
export type MediaServersResponseType = {
|
||||
servers: GenericMediaServer[];
|
||||
};
|
||||
46
src/types/api/media-server/session-info.ts
Normal file
46
src/types/api/media-server/session-info.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type GenericSessionInfo = {
|
||||
supportsMediaControl: boolean;
|
||||
username: string | undefined;
|
||||
id: string;
|
||||
sessionName: string;
|
||||
userProfilePicture: string | undefined;
|
||||
currentlyPlaying: GenericCurrentlyPlaying | undefined;
|
||||
};
|
||||
|
||||
export type GenericCurrentlyPlaying = {
|
||||
name: string;
|
||||
seasonName: string | undefined;
|
||||
albumName: string | undefined;
|
||||
episodeCount: number | undefined;
|
||||
type: 'audio' | 'video' | 'tv' | 'movie' | undefined;
|
||||
metadata: {
|
||||
video: {
|
||||
videoCodec: string | undefined;
|
||||
videoFrameRate: string | undefined;
|
||||
height: number | undefined;
|
||||
width: number | undefined;
|
||||
bitrate: number | undefined;
|
||||
} | undefined;
|
||||
audio: {
|
||||
audioCodec: string | undefined;
|
||||
audioChannels: number | undefined;
|
||||
} | undefined;
|
||||
transcoding: {
|
||||
context: string | undefined;
|
||||
sourceVideoCodec: string | undefined;
|
||||
sourceAudioCodec: string | undefined;
|
||||
videoDecision: string | undefined;
|
||||
audioDecision: string | undefined;
|
||||
container: string | undefined;
|
||||
videoCodec: string | undefined;
|
||||
audioCodec: string | undefined;
|
||||
error: boolean | undefined;
|
||||
duration: number | undefined;
|
||||
audioChannels: number | undefined;
|
||||
width: number | undefined;
|
||||
height: number | undefined;
|
||||
transcodeHwRequested: boolean | undefined;
|
||||
timeStamp: number | undefined;
|
||||
} | undefined;
|
||||
};
|
||||
};
|
||||
@@ -41,6 +41,8 @@ export type IntegrationType =
|
||||
| 'deluge'
|
||||
| 'qBittorrent'
|
||||
| 'transmission'
|
||||
| 'plex'
|
||||
| 'jellyfin'
|
||||
| 'nzbGet';
|
||||
|
||||
export type AppIntegrationType = {
|
||||
@@ -79,6 +81,8 @@ export const integrationFieldProperties: {
|
||||
nzbGet: ['username', 'password'],
|
||||
qBittorrent: ['username', 'password'],
|
||||
transmission: ['username', 'password'],
|
||||
jellyfin: ['username', 'password'],
|
||||
plex: ['apiKey'],
|
||||
};
|
||||
|
||||
export type IntegrationFieldDefinitionType = {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Serie, Datum, ResponsiveLine } from '@nivo/line';
|
||||
import { IconDownload, IconUpload } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect } from 'react';
|
||||
import { AppAvatar } from '../../components/AppAvatar';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed';
|
||||
import { useColorTheme } from '../../tools/color';
|
||||
@@ -258,17 +259,3 @@ export default function TorrentNetworkTrafficTile({ widget }: TorrentNetworkTraf
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const AppAvatar = ({ iconUrl }: { iconUrl: string }) => {
|
||||
const { colors, colorScheme } = useMantineTheme();
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
src={iconUrl}
|
||||
bg={colorScheme === 'dark' ? colors.gray[8] : colors.gray[2]}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
p={4}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
82
src/widgets/iframe/IFrameTile.tsx
Normal file
82
src/widgets/iframe/IFrameTile.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Center, createStyles, Stack, Title, Text, Container } from '@mantine/core';
|
||||
import { IconBrowser, IconUnlink } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'iframe',
|
||||
icon: IconBrowser,
|
||||
gridstack: {
|
||||
maxHeight: 12,
|
||||
maxWidth: 12,
|
||||
minHeight: 1,
|
||||
minWidth: 1,
|
||||
},
|
||||
options: {
|
||||
embedUrl: {
|
||||
type: 'text',
|
||||
defaultValue: '',
|
||||
},
|
||||
allowFullScreen: {
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
},
|
||||
},
|
||||
component: IFrameTile,
|
||||
});
|
||||
|
||||
export type IIFrameWidget = IWidget<(typeof definition)['id'], typeof definition>;
|
||||
|
||||
interface IFrameTileProps {
|
||||
widget: IIFrameWidget;
|
||||
}
|
||||
|
||||
function IFrameTile({ widget }: IFrameTileProps) {
|
||||
const { t } = useTranslation('modules/iframe');
|
||||
const { classes } = useStyles();
|
||||
|
||||
if (!widget.properties.embedUrl) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center">
|
||||
<IconUnlink size={36} strokeWidth={1.2} />
|
||||
<Stack align="center" spacing={0}>
|
||||
<Title order={6} align="center">
|
||||
{t('card.errors.noUrl.title')}
|
||||
</Title>
|
||||
<Text align="center" maw={200}>
|
||||
{t('card.errors.noUrl.text')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container h="100%" w="100%" p={0}>
|
||||
<iframe
|
||||
className={classes.iframe}
|
||||
src={widget.properties.embedUrl}
|
||||
title="widget iframe"
|
||||
allowFullScreen={widget.properties.allowFullScreen}
|
||||
>
|
||||
<Text>Your Browser does not support iframes. Please update your browser.</Text>
|
||||
</iframe>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createStyles(({ radius }) => ({
|
||||
iframe: {
|
||||
borderRadius: radius.sm,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}));
|
||||
|
||||
export default definition;
|
||||
@@ -1,11 +1,14 @@
|
||||
import date from './date/DateTile';
|
||||
import calendar from './calendar/CalendarTile';
|
||||
import dashdot from './dashDot/DashDotTile';
|
||||
import usenet from './useNet/UseNetTile';
|
||||
import weather from './weather/WeatherTile';
|
||||
import torrent from './torrent/TorrentTile';
|
||||
import date from './date/DateTile';
|
||||
import torrentNetworkTraffic from './download-speed/TorrentNetworkTrafficTile';
|
||||
import iframe from './iframe/IFrameTile';
|
||||
import mediaServer from './media-server/MediaServerTile';
|
||||
import rss from './rss/RssWidgetTile';
|
||||
import torrent from './torrent/TorrentTile';
|
||||
import usenet from './useNet/UseNetTile';
|
||||
import videoStream from './video/VideoStreamTile';
|
||||
import weather from './weather/WeatherTile';
|
||||
|
||||
export default {
|
||||
calendar,
|
||||
@@ -15,5 +18,8 @@ export default {
|
||||
'torrents-status': torrent,
|
||||
dlspeed: torrentNetworkTraffic,
|
||||
date,
|
||||
rss,
|
||||
'video-stream': videoStream,
|
||||
iframe,
|
||||
'media-server': mediaServer,
|
||||
};
|
||||
|
||||
128
src/widgets/media-server/DetailCollapseable.tsx
Normal file
128
src/widgets/media-server/DetailCollapseable.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Card, Divider, Flex, Grid, Group, Text } from '@mantine/core';
|
||||
import { IconDeviceMobile, IconId } from '@tabler/icons';
|
||||
import { GenericSessionInfo } from '../../types/api/media-server/session-info';
|
||||
|
||||
export const DetailCollapseable = ({ session }: { session: GenericSessionInfo }) => {
|
||||
let details: { title: string; metrics: { name: string; value: string | undefined }[] }[] = [];
|
||||
|
||||
if (session.currentlyPlaying) {
|
||||
if (session.currentlyPlaying.metadata.video) {
|
||||
details = [
|
||||
...details,
|
||||
{
|
||||
title: 'Video',
|
||||
metrics: [
|
||||
{
|
||||
name: 'Resolution',
|
||||
value: `${session.currentlyPlaying.metadata.video.width}x${session.currentlyPlaying.metadata.video.height}`,
|
||||
},
|
||||
{
|
||||
name: 'Framerate',
|
||||
value: session.currentlyPlaying.metadata.video.videoFrameRate,
|
||||
},
|
||||
{
|
||||
name: 'Codec',
|
||||
value: session.currentlyPlaying.metadata.video.videoCodec,
|
||||
},
|
||||
{
|
||||
name: 'Bitrate',
|
||||
value: session.currentlyPlaying.metadata.video.bitrate
|
||||
? String(session.currentlyPlaying.metadata.video.bitrate)
|
||||
: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
if (session.currentlyPlaying.metadata.audio) {
|
||||
details = [
|
||||
...details,
|
||||
{
|
||||
title: 'Audio',
|
||||
metrics: [
|
||||
{
|
||||
name: 'Audio channels',
|
||||
value: `${session.currentlyPlaying.metadata.audio.audioChannels}`,
|
||||
},
|
||||
{
|
||||
name: 'Audio codec',
|
||||
value: session.currentlyPlaying.metadata.audio.audioCodec,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (session.currentlyPlaying.metadata.transcoding) {
|
||||
details = [
|
||||
...details,
|
||||
{
|
||||
title: 'Transcoding',
|
||||
metrics: [
|
||||
{
|
||||
name: 'Resolution',
|
||||
value: `${session.currentlyPlaying.metadata.transcoding.width}x${session.currentlyPlaying.metadata.transcoding.height}`,
|
||||
},
|
||||
{
|
||||
name: 'Context',
|
||||
value: session.currentlyPlaying.metadata.transcoding.context,
|
||||
},
|
||||
{
|
||||
name: 'Hardware encoding requested',
|
||||
value: session.currentlyPlaying.metadata.transcoding.transcodeHwRequested
|
||||
? 'yes'
|
||||
: 'no',
|
||||
},
|
||||
{
|
||||
name: 'Source codec',
|
||||
value:
|
||||
session.currentlyPlaying.metadata.transcoding.sourceAudioCodec ||
|
||||
session.currentlyPlaying.metadata.transcoding.sourceVideoCodec
|
||||
? `${session.currentlyPlaying.metadata.transcoding.sourceVideoCodec} ${session.currentlyPlaying.metadata.transcoding.sourceAudioCodec}`
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
name: 'Target codec',
|
||||
value: `${session.currentlyPlaying.metadata.transcoding.videoCodec} ${session.currentlyPlaying.metadata.transcoding.audioCodec}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Flex justify="space-between" mb="xs">
|
||||
<Group>
|
||||
<IconId size={16} />
|
||||
<Text>ID</Text>
|
||||
</Group>
|
||||
<Text>{session.id}</Text>
|
||||
</Flex>
|
||||
<Flex justify="space-between" mb="md">
|
||||
<Group>
|
||||
<IconDeviceMobile size={16} />
|
||||
<Text>Device</Text>
|
||||
</Group>
|
||||
<Text>{session.sessionName}</Text>
|
||||
</Flex>
|
||||
{details.length > 0 && <Divider label="Stats for nerds" labelPosition="center" mt="lg" mb="sm" />}
|
||||
<Grid>
|
||||
{details.map((detail, index) => (
|
||||
<Grid.Col xs={12} sm={6} key={index}>
|
||||
<Text weight="bold">{detail.title}</Text>
|
||||
{detail.metrics
|
||||
.filter((x) => x.value !== undefined)
|
||||
.map((metric, index2) => (
|
||||
<Group position="apart" key={index2}>
|
||||
<Text>{metric.name}</Text>
|
||||
<Text>{metric.value}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
110
src/widgets/media-server/MediaServerTile.tsx
Normal file
110
src/widgets/media-server/MediaServerTile.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Avatar,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconMovie } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { AppAvatar } from '../../components/AppAvatar';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { useGetMediaServers } from '../../hooks/widgets/media-servers/useGetMediaServers';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
import { TableRow } from './TableRow';
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'media-server',
|
||||
icon: IconMovie,
|
||||
options: {},
|
||||
component: MediaServerTile,
|
||||
gridstack: {
|
||||
minWidth: 3,
|
||||
minHeight: 2,
|
||||
maxWidth: 12,
|
||||
maxHeight: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export type MediaServerWidget = IWidget<(typeof definition)['id'], typeof definition>;
|
||||
|
||||
interface MediaServerWidgetProps {
|
||||
widget: MediaServerWidget;
|
||||
}
|
||||
|
||||
function MediaServerTile({ widget }: MediaServerWidgetProps) {
|
||||
const { t } = useTranslation('modules/media-server');
|
||||
const { config } = useConfigContext();
|
||||
|
||||
const { data, isError } = useGetMediaServers({
|
||||
enabled: config !== undefined,
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Center>
|
||||
<Stack align="center">
|
||||
<IconAlertTriangle />
|
||||
<Title order={6}>{t('card.errors.general.title')}</Title>
|
||||
<Text>{t('card.errors.general.text')}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
<Center h="100%">
|
||||
<Loader />
|
||||
</Center>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%">
|
||||
<ScrollArea offsetScrollbars>
|
||||
<Table highlightOnHover striped>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('card.table.header.session')}</th>
|
||||
<th>{t('card.table.header.user')}</th>
|
||||
<th>{t('card.table.header.currentlyPlaying')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.servers.map((server) => {
|
||||
const app = config?.apps.find((x) => x.id === server.appId);
|
||||
return server.sessions.map((session, index) => (
|
||||
<TableRow session={session} app={app} key={index} />
|
||||
));
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
|
||||
<Group position="right" mt="auto">
|
||||
<Avatar.Group>
|
||||
{data?.servers.map((server) => {
|
||||
const app = config?.apps.find((x) => x.id === server.appId);
|
||||
|
||||
if (!app) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppAvatar
|
||||
iconUrl={app.appearance.iconUrl}
|
||||
color={server.success === true ? undefined : 'red'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Avatar.Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default definition;
|
||||
50
src/widgets/media-server/NowPlayingDisplay.tsx
Normal file
50
src/widgets/media-server/NowPlayingDisplay.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Flex, Group, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
IconDeviceTv,
|
||||
IconHeadphones,
|
||||
IconQuestionMark,
|
||||
IconVideo,
|
||||
TablerIcon,
|
||||
} from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { GenericSessionInfo } from '../../types/api/media-server/session-info';
|
||||
|
||||
export const NowPlayingDisplay = ({ session }: { session: GenericSessionInfo }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!session.currentlyPlaying) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Icon = (): TablerIcon => {
|
||||
switch (session.currentlyPlaying?.type) {
|
||||
case 'audio':
|
||||
return IconHeadphones;
|
||||
case 'tv':
|
||||
return IconDeviceTv;
|
||||
case 'video':
|
||||
return IconVideo;
|
||||
default:
|
||||
return IconQuestionMark;
|
||||
}
|
||||
};
|
||||
|
||||
const Test = Icon();
|
||||
|
||||
return (
|
||||
<Flex wrap="nowrap" gap="sm" align="center">
|
||||
<Test size={16} />
|
||||
<Stack spacing={0}>
|
||||
<Text lineClamp={1}>{session.currentlyPlaying.name}</Text>
|
||||
|
||||
{session.currentlyPlaying.albumName ? (
|
||||
<Text lineClamp={1} color="dimmed" size="xs">{session.currentlyPlaying.albumName}</Text>
|
||||
) : (
|
||||
session.currentlyPlaying.seasonName && (
|
||||
<Text lineClamp={1} color="dimmed" size="xs">{session.currentlyPlaying.seasonName}</Text>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
73
src/widgets/media-server/TableRow.tsx
Normal file
73
src/widgets/media-server/TableRow.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Avatar,
|
||||
Card,
|
||||
Collapse,
|
||||
createStyles,
|
||||
Flex,
|
||||
Grid,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { AppAvatar } from '../../components/AppAvatar';
|
||||
import { GenericSessionInfo } from '../../types/api/media-server/session-info';
|
||||
import { AppType } from '../../types/app';
|
||||
import { DetailCollapseable } from './DetailCollapseable';
|
||||
import { NowPlayingDisplay } from './NowPlayingDisplay';
|
||||
|
||||
interface TableRowProps {
|
||||
session: GenericSessionInfo;
|
||||
app: AppType | undefined;
|
||||
}
|
||||
|
||||
export const TableRow = ({ session, app }: TableRowProps) => {
|
||||
const [collapseOpen, setCollapseOpen] = useState(false);
|
||||
const hasUserThumb = session.userProfilePicture !== undefined;
|
||||
const { classes } = useStyles();
|
||||
return (
|
||||
<>
|
||||
<tr className={classes.dataRow} onClick={() => setCollapseOpen(!collapseOpen)}>
|
||||
<td>
|
||||
<Flex wrap="nowrap" gap="xs">
|
||||
{app?.appearance.iconUrl && <AppAvatar iconUrl={app.appearance.iconUrl} />}
|
||||
<Text lineClamp={1}>{session.sessionName}</Text>
|
||||
</Flex>
|
||||
</td>
|
||||
<td>
|
||||
<Flex wrap="nowrap" gap="sm">
|
||||
{hasUserThumb ? (
|
||||
<Avatar src={session.userProfilePicture} size="sm" />
|
||||
) : (
|
||||
<Avatar src={null} alt={session.username} size="sm">
|
||||
{session.username?.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
)}
|
||||
<Text>{session.username}</Text>
|
||||
</Flex>
|
||||
</td>
|
||||
<td>
|
||||
<NowPlayingDisplay session={session} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className={classes.collapseTableDataCell} colSpan={3}>
|
||||
<Collapse in={collapseOpen} w="100%">
|
||||
<DetailCollapseable session={session} />
|
||||
</Collapse>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
dataRow: {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
collapseTableDataCell: {
|
||||
border: 'none !important',
|
||||
padding: '0 !important',
|
||||
},
|
||||
}));
|
||||
236
src/widgets/rss/RssWidgetTile.tsx
Normal file
236
src/widgets/rss/RssWidgetTile.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Card,
|
||||
Center,
|
||||
createStyles,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
LoadingOverlay,
|
||||
MediaQuery,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import {
|
||||
IconBulldozer,
|
||||
IconCalendarTime,
|
||||
IconClock,
|
||||
IconCopyright,
|
||||
IconRefresh,
|
||||
IconRss,
|
||||
IconSpeakerphone,
|
||||
} from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useGetRssFeed } from '../../hooks/widgets/rss/useGetRssFeed';
|
||||
import { sleep } from '../../tools/client/time';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'rss',
|
||||
icon: IconRss,
|
||||
options: {
|
||||
rssFeedUrl: {
|
||||
type: 'text',
|
||||
defaultValue: '',
|
||||
},
|
||||
},
|
||||
gridstack: {
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
maxWidth: 12,
|
||||
maxHeight: 12,
|
||||
},
|
||||
component: RssTile,
|
||||
});
|
||||
|
||||
export type IRssWidget = IWidget<(typeof definition)['id'], typeof definition>;
|
||||
|
||||
interface RssTileProps {
|
||||
widget: IRssWidget;
|
||||
}
|
||||
|
||||
function RssTile({ widget }: RssTileProps) {
|
||||
const { t } = useTranslation('modules/rss');
|
||||
const { data, isLoading, isFetching, isError, refetch } = useGetRssFeed(
|
||||
widget.properties.rssFeedUrl
|
||||
);
|
||||
const { classes } = useStyles();
|
||||
const [loadingOverlayVisible, setLoadingOverlayVisible] = useState(false);
|
||||
const { ref, height } = useElementSize();
|
||||
|
||||
if (!data || isLoading) {
|
||||
return (
|
||||
<Center>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.success || isError) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center">
|
||||
<IconRss size={40} strokeWidth={1} />
|
||||
<Title order={6}>{t('card.errors.general.title')}</Title>
|
||||
<Text align="center">{t('card.errors.general.text')}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack ref={ref} h="100%">
|
||||
<LoadingOverlay visible={loadingOverlayVisible} />
|
||||
<Flex gap="md">
|
||||
{data.feed.image ? (
|
||||
<Image
|
||||
src={data.feed.image.url}
|
||||
alt={data.feed.image.title}
|
||||
width="auto"
|
||||
height={40}
|
||||
mx="auto"
|
||||
/>
|
||||
) : (
|
||||
<Title order={6}>{data.feed.title}</Title>
|
||||
)}
|
||||
<UnstyledButton
|
||||
onClick={async () => {
|
||||
setLoadingOverlayVisible(true);
|
||||
await Promise.all([sleep(1500), refetch()]);
|
||||
setLoadingOverlayVisible(false);
|
||||
}}
|
||||
disabled={isFetching || isLoading}
|
||||
>
|
||||
<ActionIcon>
|
||||
<IconRefresh />
|
||||
</ActionIcon>
|
||||
</UnstyledButton>
|
||||
</Flex>
|
||||
<ScrollArea className="scroll-area-w100" w="100%">
|
||||
<Stack w="100%" spacing="xs">
|
||||
{data.feed.items.map((item: any, index: number) => (
|
||||
<Card
|
||||
key={index}
|
||||
withBorder
|
||||
component={Link}
|
||||
href={item.link}
|
||||
radius="md"
|
||||
target="_blank"
|
||||
w="100%"
|
||||
>
|
||||
{item.enclosure && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className={classes.backgroundImage}
|
||||
src={item.enclosure.url ?? undefined}
|
||||
alt="backdrop"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Flex gap="xs">
|
||||
<MediaQuery query="(max-width: 1200px)" styles={{ display: 'none' }}>
|
||||
<Image
|
||||
src={item.enclosure?.url ?? undefined}
|
||||
width={140}
|
||||
height={140}
|
||||
radius="md"
|
||||
withPlaceholder
|
||||
/>
|
||||
</MediaQuery>
|
||||
<Flex gap={2} direction="column">
|
||||
{item.categories && (
|
||||
<Flex gap="xs" wrap="wrap" h={20} style={{ overflow: 'hidden' }}>
|
||||
{item.categories.map((category: any, categoryIndex: number) => (
|
||||
<Badge key={categoryIndex}>{category._}</Badge>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Text lineClamp={2}>{item.title}</Text>
|
||||
<Text color="dimmed" size="xs" lineClamp={3}>
|
||||
{item.content}
|
||||
</Text>
|
||||
|
||||
{item.pubDate && <TimeDisplay date={item.pubDate} />}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
<Flex wrap="wrap" columnGap="md">
|
||||
<Group spacing="sm">
|
||||
<IconCopyright size={14} />
|
||||
<Text color="dimmed" size="sm">
|
||||
{data.feed.copyright}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
<IconCalendarTime size={14} />
|
||||
<Text color="dimmed" size="sm">
|
||||
{data.feed.pubDate}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
<IconBulldozer size={14} />
|
||||
<Text color="dimmed" size="sm">
|
||||
{data.feed.lastBuildDate}
|
||||
</Text>
|
||||
</Group>
|
||||
{data.feed.feedUrl && (
|
||||
<Group spacing="sm">
|
||||
<IconSpeakerphone size={14} />
|
||||
<Text
|
||||
color="dimmed"
|
||||
size="sm"
|
||||
variant="link"
|
||||
target="_blank"
|
||||
component={Link}
|
||||
href={data.feed.feedUrl}
|
||||
>
|
||||
Feed URL
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Flex>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const TimeDisplay = ({ date }: { date: string }) => (
|
||||
<Group mt="auto" spacing="xs">
|
||||
<IconClock size={14} />
|
||||
<Text size="xs" color="dimmed">
|
||||
{date}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
const useStyles = createStyles(({ colorScheme }) => ({
|
||||
backgroundImage: {
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
filter: colorScheme === 'dark' ? 'blur(30px)' : 'blur(15px)',
|
||||
transform: 'scaleX(-1)',
|
||||
opacity: colorScheme === 'dark' ? 0.3 : 0.2,
|
||||
transition: 'ease-in-out 0.2s',
|
||||
|
||||
'&:hover': {
|
||||
opacity: colorScheme === 'dark' ? 0.4 : 0.3,
|
||||
filter: 'blur(40px) brightness(0.7)',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export default definition;
|
||||
Reference in New Issue
Block a user