🔀 Merge branch 'dev' into feature/dashdot-consistency-changes

This commit is contained in:
Manuel
2023-02-17 23:41:49 +01:00
28 changed files with 1558 additions and 165 deletions

View File

@@ -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}
/>
);
};

View 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;

View File

@@ -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,
};

View 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>
);
};

View 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;

View 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>
);
};

View 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',
},
}));

View 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;