🔀 Merge dev to auth branch
This commit is contained in:
@@ -67,6 +67,16 @@ export const AppearanceTab = ({
|
||||
/>
|
||||
{form.values.appearance.appNameStatus === 'normal' && (
|
||||
<>
|
||||
<NumberInput
|
||||
label={t('appearance.appNameFontSize.label')}
|
||||
description={t('appearance.appNameFontSize.description')}
|
||||
min={5}
|
||||
max={64}
|
||||
{...form.getInputProps('appearance.appNameFontSize')}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('appearance.appNameFontSize', value);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label={t('appearance.positionAppName.label')}
|
||||
description={t('appearance.positionAppName.description')}
|
||||
|
||||
@@ -94,6 +94,7 @@ export const AvailableElementTypes = ({
|
||||
appearance: {
|
||||
iconUrl: '/imgs/logo/logo.png',
|
||||
appNameStatus: 'normal',
|
||||
appNameFontSize: 16,
|
||||
positionAppName: 'column',
|
||||
lineClampAppName: 1,
|
||||
},
|
||||
|
||||
@@ -49,7 +49,7 @@ export const AppTile = ({ className, app }: AppTileProps) => {
|
||||
<Text
|
||||
className={cx(classes.appName, 'dashboard-tile-app-title')}
|
||||
fw={700}
|
||||
size="md"
|
||||
size={app.appearance.appNameFontSize}
|
||||
ta="center"
|
||||
sx={{
|
||||
flex: isRow ? '1' : undefined,
|
||||
@@ -60,19 +60,12 @@ export const AppTile = ({ className, app }: AppTileProps) => {
|
||||
</Text>
|
||||
)}
|
||||
<motion.img
|
||||
className={cx('dashboard-tile-app-image')}
|
||||
className={cx(classes.appImage, 'dashboard-tile-app-image')}
|
||||
src={app.appearance.iconUrl}
|
||||
height="85%"
|
||||
width="85%"
|
||||
alt={app.name}
|
||||
whileHover={{ scale: 0.9 }}
|
||||
initial={{ scale: 0.8 }}
|
||||
style={{
|
||||
maxHeight: '90%',
|
||||
maxWidth: '90%',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
objectFit: 'contain',
|
||||
width: isRow ? 0 : undefined,
|
||||
}}
|
||||
/>
|
||||
@@ -118,6 +111,13 @@ const useStyles = createStyles((theme, _params, getRef) => ({
|
||||
overflow: 'visible',
|
||||
flexGrow: 5,
|
||||
},
|
||||
appImage:{
|
||||
maxHeight: '100%',
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto',
|
||||
flex: 1,
|
||||
objectFit: 'contain',
|
||||
},
|
||||
appName: {
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
|
||||
@@ -48,13 +48,13 @@ export const CategoryEditMenu = ({ category }: CategoryEditMenuProps) => {
|
||||
{t('menu.moveDown')}
|
||||
</Menu.Item>
|
||||
<Menu.Label>
|
||||
{t('menu.addCategory')}
|
||||
{t('menu.addCategory',{location: ''})}
|
||||
</Menu.Label>
|
||||
<Menu.Item icon={<IconRowInsertTop size={20} />} onClick={addCategoryAbove}>
|
||||
{t('menu.addCategory') + ' ' + t('menu.addAbove')}
|
||||
{t('menu.addCategory',{location: t('menu.addAbove')})}
|
||||
</Menu.Item>
|
||||
<Menu.Item icon={<IconRowInsertBottom size={20} />} onClick={addCategoryBelow}>
|
||||
{t('menu.addCategory') + ' ' + t('menu.addBelow')}
|
||||
{t('menu.addCategory',{location: t('menu.addBelow')})}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@@ -163,7 +163,7 @@ export const AboutModal = ({ opened, closeModal, newVersionAvailable }: AboutMod
|
||||
variant="default"
|
||||
fullWidth
|
||||
>
|
||||
Documentation
|
||||
{t('layout/modals/about:documentation')}
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -198,6 +198,7 @@ interface ExtendedInitOptions extends InitOptions {
|
||||
const useInformationTableItems = (newVersionAvailable?: string): InformationTableItem[] => {
|
||||
const { attributes } = usePackageAttributesStore();
|
||||
const { primaryColor } = useColorTheme();
|
||||
const { t } = useTranslation(['layout/modals/about']);
|
||||
|
||||
const { configVersion } = useConfigContext();
|
||||
const { configs } = useConfigStore();
|
||||
@@ -270,21 +271,23 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
|
||||
transition={{ duration: 0.8, ease: 'easeInOut' }}
|
||||
>
|
||||
<Badge color="green" variant="filled">
|
||||
new: {newVersionAvailable}
|
||||
{t('version.new',{ newVersion: newVersionAvailable})}
|
||||
</Badge>
|
||||
</motion.div>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
Version{' '}
|
||||
<b>
|
||||
<Anchor
|
||||
target="_blank"
|
||||
href={`https://github.com/ajnart/homarr/releases/tag/${newVersionAvailable}`}
|
||||
>
|
||||
{newVersionAvailable}
|
||||
</Anchor>
|
||||
</b>{' '}
|
||||
is available ! Current version: {attributes.packageVersion}
|
||||
<Text>
|
||||
{t('version.dropdown', {currentVersion: attributes.packageVersion}).split('{{newVersion}}')[0]}
|
||||
<b>
|
||||
<Anchor
|
||||
target="_blank"
|
||||
href={`https://github.com/ajnart/homarr/releases/tag/${newVersionAvailable}`}
|
||||
>
|
||||
{newVersionAvailable}
|
||||
</Anchor>
|
||||
</b>
|
||||
{t('version.dropdown', {currentVersion: attributes.packageVersion}).split('{{newVersion}}')[1]}
|
||||
</Text>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
)}
|
||||
|
||||
@@ -28,7 +28,6 @@ import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../data/constants
|
||||
import nextI18nextConfig from '../../next-i18next.config.js';
|
||||
import { ConfigProvider } from '~/config/provider';
|
||||
import '../styles/global.scss';
|
||||
import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore';
|
||||
import { ColorTheme } from '~/tools/color';
|
||||
import {
|
||||
ServerSidePackageAttributesType,
|
||||
@@ -90,12 +89,6 @@ function App(
|
||||
};
|
||||
}, [props.pageProps]);
|
||||
|
||||
const { setInitialPackageAttributes } = usePackageAttributesStore();
|
||||
|
||||
useEffect(() => {
|
||||
setInitialPackageAttributes(props.pageProps.packageAttributes);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommonHead />
|
||||
|
||||
@@ -43,7 +43,7 @@ export const appRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
Consola.error(`Ping timed out for app with id : ${input} (url: ${app.url})`);
|
||||
Consola.error(`Ping timed out for app with id '${input.id}' in config '${input.configName}' -> url: ${app.url})`);
|
||||
throw new TRPCError({
|
||||
code: 'TIMEOUT',
|
||||
cause: input,
|
||||
|
||||
@@ -16,6 +16,7 @@ export const calendarRouter = createTRPCRouter({
|
||||
year: z.number().min(1900).max(2300),
|
||||
options: z.object({
|
||||
useSonarrv4: z.boolean().optional().default(false),
|
||||
showUnmonitored: z.boolean().optional().default(false),
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -64,7 +65,9 @@ export const calendarRouter = createTRPCRouter({
|
||||
if (!apiKey) return { type: integration.type, items: [], success: false };
|
||||
return axios
|
||||
.get(
|
||||
`${origin}${endpoint}?apiKey=${apiKey}&end=${end.toISOString()}&start=${start.toISOString()}&includeSeries=true&includeEpisodeFile=true&includeEpisodeImages=true`
|
||||
`${origin}${endpoint}?apiKey=${apiKey}&end=${end.toISOString()}&start=${start.toISOString()}&includeSeries=true&includeEpisodeFile=true&includeEpisodeImages=true&&unmonitored=${
|
||||
input.options.showUnmonitored
|
||||
}`
|
||||
)
|
||||
.then((x) => ({ type: integration.type, items: x.data as any[], success: true }))
|
||||
.catch((err) => {
|
||||
|
||||
@@ -67,6 +67,12 @@ export const configRouter = createTRPCRouter({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
if (process.env.DISABLE_EDIT_MODE?.toLowerCase() === 'true') {
|
||||
throw new TRPCError({
|
||||
code: 'METHOD_NOT_SUPPORTED',
|
||||
message: 'Edit is not allowed, because edit mode is disabled'
|
||||
});
|
||||
}
|
||||
Consola.info(`Saving updated configuration of '${input.name}' config.`);
|
||||
|
||||
const previousConfig = getConfig(input.name);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Consola from 'consola';
|
||||
import { z } from 'zod';
|
||||
import { findAppProperty } from '~/tools/client/app-properties';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
@@ -14,19 +15,25 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
z.object({
|
||||
action: z.enum(['enable', 'disable']),
|
||||
configName: z.string(),
|
||||
appsToChange: z.optional(z.array(z.string())),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
|
||||
const applicableApps = config.apps.filter(
|
||||
(x) => x.integration?.type && ['pihole', 'adGuardHome'].includes(x.integration?.type)
|
||||
(app) =>
|
||||
app.id &&
|
||||
app.integration?.type &&
|
||||
input.appsToChange?.includes(app.id) &&
|
||||
['pihole', 'adGuardHome'].includes(app.integration?.type)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
applicableApps.map(async (app) => {
|
||||
if (app.integration?.type === 'pihole') {
|
||||
await processPiHole(app, input.action === 'enable');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,8 +79,6 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
}
|
||||
);
|
||||
|
||||
//const data: AdStatistics = ;
|
||||
|
||||
data.adsBlockedTodayPercentage = data.adsBlockedToday / data.dnsQueriesToday;
|
||||
if (Number.isNaN(data.adsBlockedTodayPercentage)) {
|
||||
data.adsBlockedTodayPercentage = 0;
|
||||
@@ -90,22 +95,38 @@ const processAdGuard = async (app: ConfigAppType, enable: boolean) => {
|
||||
);
|
||||
|
||||
if (enable) {
|
||||
await adGuard.disable();
|
||||
try {
|
||||
await adGuard.enable();
|
||||
} catch (error) {
|
||||
Consola.error((error as Error).message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await adGuard.enable();
|
||||
try {
|
||||
await adGuard.disable();
|
||||
} catch (error) {
|
||||
Consola.error((error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const processPiHole = async (app: ConfigAppType, enable: boolean) => {
|
||||
const pihole = new PiHoleClient(app.url, findAppProperty(app, 'apiKey'));
|
||||
|
||||
if (enable) {
|
||||
await pihole.enable();
|
||||
try {
|
||||
await pihole.enable();
|
||||
} catch (error) {
|
||||
Consola.error((error as Error).message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await pihole.disable();
|
||||
try {
|
||||
await pihole.disable();
|
||||
} catch (error) {
|
||||
Consola.error((error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const collectPiHoleSummary = async (app: ConfigAppType) => {
|
||||
|
||||
@@ -3,15 +3,18 @@ import { z } from 'zod';
|
||||
import { checkIntegrationsType } from '~/tools/client/app-properties';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { MediaRequestListWidget } from '~/widgets/media-requests/MediaRequestListTile';
|
||||
import { MediaRequest } from '~/widgets/media-requests/media-request-types';
|
||||
import { MediaRequest, Users } from '~/widgets/media-requests/media-request-types';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
import { MediaRequestStatsWidget } from '~/widgets/media-requests/MediaRequestStatsTile';
|
||||
import { removeTrailingSlash } from 'next/dist/shared/lib/router/utils/remove-trailing-slash';
|
||||
|
||||
export const mediaRequestsRouter = createTRPCRouter({
|
||||
all: publicProcedure
|
||||
allMedia: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
widget: z.custom<MediaRequestListWidget>().or(z.custom<MediaRequestStatsWidget>()),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
@@ -21,8 +24,6 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
checkIntegrationsType(app.integration, ['overseerr', 'jellyseerr'])
|
||||
);
|
||||
|
||||
Consola.log(`Retrieving media requests from ${apps.length} apps`);
|
||||
|
||||
const promises = apps.map((app): Promise<MediaRequest[]> => {
|
||||
const apiKey =
|
||||
app.integration?.properties.find((prop) => prop.field === 'apiKey')?.value ?? '';
|
||||
@@ -32,17 +33,12 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
})
|
||||
.then(async (response) => {
|
||||
const body = (await response.json()) as OverseerrResponse;
|
||||
const mediaWidget = config.widgets.find((x) => x.type === 'media-requests-list') as
|
||||
| MediaRequestListWidget
|
||||
| undefined;
|
||||
if (!mediaWidget) {
|
||||
Consola.log('No media-requests-list found');
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
const appUrl = mediaWidget.properties.replaceLinksWithExternalHost
|
||||
let appUrl = input.widget.properties.replaceLinksWithExternalHost && app.behaviour.externalUrl?.length > 0
|
||||
? app.behaviour.externalUrl
|
||||
: app.url;
|
||||
|
||||
appUrl = removeTrailingSlash(appUrl);
|
||||
|
||||
const requests = await Promise.all(
|
||||
body.results.map(async (item): Promise<MediaRequest> => {
|
||||
const genericItem = await retrieveDetailsForItem(
|
||||
@@ -59,8 +55,9 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
type: item.type,
|
||||
name: genericItem.name,
|
||||
userName: item.requestedBy.displayName,
|
||||
userProfilePicture: constructAvatarUrl(appUrl, item),
|
||||
userProfilePicture: constructAvatarUrl(appUrl, item.requestedBy.avatar),
|
||||
userLink: `${appUrl}/users/${item.requestedBy.id}`,
|
||||
userRequestCount: item.requestedBy.requestCount,
|
||||
airDate: genericItem.airDate,
|
||||
status: item.status,
|
||||
backdropPath: `https://image.tmdb.org/t/p/original/${genericItem.backdropPath}`,
|
||||
@@ -85,17 +82,66 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
|
||||
return mediaRequests;
|
||||
}),
|
||||
users: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
configName: z.string(),
|
||||
widget: z.custom<MediaRequestListWidget>().or(z.custom<MediaRequestStatsWidget>()),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const config = getConfig(input.configName);
|
||||
|
||||
const apps = config.apps.filter((app) =>
|
||||
checkIntegrationsType(app.integration, ['overseerr', 'jellyseerr'])
|
||||
);
|
||||
|
||||
const promises = apps.map((app): Promise<Users[]> => {
|
||||
const apiKey =
|
||||
app.integration?.properties.find((prop) => prop.field === 'apiKey')?.value ?? '';
|
||||
const headers: HeadersInit = { 'X-Api-Key': apiKey };
|
||||
return fetch(`${app.url}/api/v1/user?take=25&skip=0&sort=requests`, {
|
||||
headers,
|
||||
})
|
||||
.then(async (response) => {
|
||||
const body = (await response.json()) as OverseerrUsers;
|
||||
const appUrl = input.widget.properties.replaceLinksWithExternalHost
|
||||
? app.behaviour.externalUrl
|
||||
: app.url;
|
||||
|
||||
const users = await Promise.all(
|
||||
body.results.map(async (user): Promise<Users> => {
|
||||
return {
|
||||
app: app.integration?.type ?? 'overseerr',
|
||||
id: user.id,
|
||||
userName: user.displayName,
|
||||
userProfilePicture: constructAvatarUrl(appUrl, user.avatar),
|
||||
userLink: `${appUrl}/users/${user.id}`,
|
||||
userRequestCount: user.requestCount,
|
||||
};
|
||||
})
|
||||
);
|
||||
return Promise.resolve(users);
|
||||
})
|
||||
.catch((err) => {
|
||||
Consola.error(`Failed to request users from Overseerr: ${err}`);
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
});
|
||||
const users = (await Promise.all(promises)).reduce((prev, cur) => prev.concat(cur), []);
|
||||
|
||||
return users;
|
||||
}),
|
||||
});
|
||||
|
||||
const constructAvatarUrl = (appUrl: string, item: OverseerrResponseItem) => {
|
||||
const isAbsolute =
|
||||
item.requestedBy.avatar.startsWith('http://') || item.requestedBy.avatar.startsWith('https://');
|
||||
const constructAvatarUrl = (appUrl: string, avatar: string) => {
|
||||
const isAbsolute = avatar.startsWith('http://') || avatar.startsWith('https://');
|
||||
|
||||
if (isAbsolute) {
|
||||
return item.requestedBy.avatar;
|
||||
return avatar;
|
||||
}
|
||||
|
||||
return `${appUrl}/${item.requestedBy.avatar}`;
|
||||
return `${appUrl}/${avatar}`;
|
||||
};
|
||||
|
||||
const retrieveDetailsForItem = async (
|
||||
@@ -117,7 +163,7 @@ const retrieveDetailsForItem = async (
|
||||
backdropPath: series.backdropPath,
|
||||
posterPath: series.backdropPath,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const movieResponse = await fetch(`${baseUrl}/api/v1/movie/${id}`, {
|
||||
headers,
|
||||
@@ -158,6 +204,10 @@ type OverseerrResponse = {
|
||||
results: OverseerrResponseItem[];
|
||||
};
|
||||
|
||||
type OverseerrUsers = {
|
||||
results: OverseerrResponseItemUser[];
|
||||
};
|
||||
|
||||
type OverseerrResponseItem = {
|
||||
id: number;
|
||||
status: number;
|
||||
@@ -176,4 +226,5 @@ type OverseerrResponseItemUser = {
|
||||
id: number;
|
||||
displayName: string;
|
||||
avatar: string;
|
||||
requestCount: number;
|
||||
};
|
||||
|
||||
@@ -100,72 +100,74 @@ const handleServer = async (app: ConfigAppType): Promise<GenericMediaServer | un
|
||||
const infoApi = await getSystemApi(api).getPublicSystemInfo();
|
||||
await api.authenticateUserByName(username, password);
|
||||
const sessionApi = await getSessionApi(api);
|
||||
const sessions = await sessionApi.getSessions();
|
||||
const { data: 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.SeriesName ?? session.NowPlayingItem.Name}`,
|
||||
seasonName: session.NowPlayingItem.SeasonName as string,
|
||||
episodeName: session.NowPlayingItem.Name as string,
|
||||
albumName: session.NowPlayingItem.Album as string,
|
||||
episodeCount: session.NowPlayingItem.EpisodeCount ?? undefined,
|
||||
metadata: {
|
||||
video:
|
||||
session.NowPlayingItem &&
|
||||
session.NowPlayingItem.Width &&
|
||||
session.NowPlayingItem.Height
|
||||
sessions: sessions
|
||||
.filter((session) => session.NowPlayingItem)
|
||||
.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.SeriesName ?? session.NowPlayingItem.Name}`,
|
||||
seasonName: session.NowPlayingItem.SeasonName as string,
|
||||
episodeName: session.NowPlayingItem.Name 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
|
||||
? {
|
||||
videoCodec: undefined,
|
||||
width: session.NowPlayingItem.Width ?? undefined,
|
||||
height: session.NowPlayingItem.Height ?? undefined,
|
||||
bitrate: undefined,
|
||||
videoFrameRate: session.TranscodingInfo?.Framerate
|
||||
? String(session.TranscodingInfo?.Framerate)
|
||||
: undefined,
|
||||
audioChannels: session.TranscodingInfo.AudioChannels ?? undefined,
|
||||
audioCodec: session.TranscodingInfo.AudioCodec ?? 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,
|
||||
})
|
||||
),
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const notebookRouter = createTRPCRouter({
|
||||
.input(z.object({ widgetId: z.string(), content: z.string(), configName: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
//TODO: #1305 Remove use of DISABLE_EDIT_MODE for auth update
|
||||
if (!process.env.DISABLE_EDIT_MODE) {
|
||||
if (process.env.DISABLE_EDIT_MODE?.toLowerCase() === 'true') {
|
||||
throw new TRPCError({
|
||||
code: 'METHOD_NOT_SUPPORTED',
|
||||
message: 'Edit is not allowed, because edit mode is disabled'
|
||||
|
||||
@@ -62,13 +62,17 @@ export const rssRouter = createTRPCRouter({
|
||||
| IRssWidget
|
||||
| undefined;
|
||||
|
||||
if (!rssWidget || input.feedUrls.length === 0) {
|
||||
if (!rssWidget) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'required widget does not exist',
|
||||
});
|
||||
}
|
||||
|
||||
if (input.feedUrls.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result = await Promise.all(
|
||||
input.feedUrls.map(async (feedUrl) =>
|
||||
getFeedUrl(feedUrl, rssWidget.properties.dangerousAllowSanitizedItemContent)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { find } from 'geo-tz'
|
||||
const GeoTz = require('browser-geo-tz/dist/geotz.js');
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
@@ -12,6 +12,7 @@ export const timezoneRouter = createTRPCRouter({
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return find(input.latitude,input.longitude)[0];
|
||||
const timezone = GeoTz.find(input.latitude,input.longitude);
|
||||
return Array.isArray(timezone) ? timezone[0] : timezone;
|
||||
}),
|
||||
})
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
|
||||
// Styling for grid-stack main area
|
||||
@for $i from 1 to 21 {
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack>.grid-stack-item[gs-w="#{$i}"] { width: calc(100% / #{var(--gridstack-column-count)} * #{$i}) }
|
||||
.grid-stack>.grid-stack-item[gs-min-w="#{$i}"] { min-width: calc(100% / #{var(--gridstack-column-count)} * #{$i}) }
|
||||
.grid-stack>.grid-stack-item[gs-max-w="#{$i}"] { max-width: calc(100% / #{var(--gridstack-column-count)} * #{$i}) }
|
||||
@@ -30,7 +30,7 @@
|
||||
.grid-stack>.grid-stack-item[gs-max-h="#{$i}"] { max-height: calc(#{$i}px * #{var(--gridstack-widget-width)}) }
|
||||
}
|
||||
|
||||
@for $i from 1 to 21 {
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack>.grid-stack-item[gs-x="#{$i}"] { left: calc(100% / #{var(--gridstack-column-count)} * #{$i}) }
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
}
|
||||
|
||||
// Styling for sidebar grid-stack elements
|
||||
@for $i from 1 to 3 {
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack.grid-stack-sidebar>.grid-stack-item[gs-w="#{$i}"] { width: 128px * $i }
|
||||
.grid-stack.grid-stack-sidebar>.grid-stack-item[gs-min-w="#{$i}"] { min-width: 128px * $i }
|
||||
.grid-stack.grid-stack-sidebar>.grid-stack-item[gs-max-w="#{$i}"] { max-width: 128px * $i }
|
||||
|
||||
@@ -141,6 +141,7 @@ const migrateAppConfigs = (config: BackendConfigType) => {
|
||||
...app.appearance,
|
||||
appNameStatus: app.appearance.appNameStatus?? 'normal',
|
||||
positionAppName: app.appearance.positionAppName?? 'column',
|
||||
appNameFontSize: app.appearance.appNameFontSize?? 16,
|
||||
lineClampAppName: app.appearance.lineClampAppName?? 1,
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import Consola from 'consola';
|
||||
import { z } from 'zod';
|
||||
import { trimStringEnding } from '~/tools/shared/strings';
|
||||
import {
|
||||
@@ -60,19 +62,41 @@ export class AdGuard {
|
||||
await this.changeProtectionStatus(false);
|
||||
}
|
||||
async enable() {
|
||||
await this.changeProtectionStatus(false);
|
||||
await this.changeProtectionStatus(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a post request to the AdGuard API to change the protection status based on the value of newStatus
|
||||
* @param {boolean} newStatus - The new status of the protection
|
||||
* @param {number} duration - Duration of a pause, in milliseconds. Enabled should be false.
|
||||
* @returns {string} - The response from the AdGuard API
|
||||
*/
|
||||
private async changeProtectionStatus(newStatus: boolean, duration = 0) {
|
||||
await fetch(`${this.baseHostName}/control/protection`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
enabled: newStatus,
|
||||
duration,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const { data }: { data: string } = await axios.post(
|
||||
`${this.baseHostName}/control/protection`,
|
||||
{
|
||||
enabled: newStatus,
|
||||
duration,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
Consola.error(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* It return a base64 username:password string
|
||||
* @returns {string} The base64 encoded username and password
|
||||
*/
|
||||
private getAuthorizationHeaderValue() {
|
||||
return Buffer.from(`${this.username}:${this.password}`).toString('base64');
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ describe('PiHole API client', () => {
|
||||
const warningLogSpy = vi.spyOn(Consola, 'warn');
|
||||
|
||||
let calledCount = 0;
|
||||
let countTriedRequests = 0;
|
||||
|
||||
fetchMock.mockResponse((request) => {
|
||||
if (request.url === 'http://pi.hole/admin/api.php?enable&auth=nice') {
|
||||
@@ -170,6 +171,13 @@ describe('PiHole API client', () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (request.url === 'http://pi.hole/admin/api.php?summaryRaw&auth=nice'){
|
||||
countTriedRequests += 1;
|
||||
return JSON.stringify({
|
||||
status: 'enabled',
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Bad url: ${request.url}`));
|
||||
});
|
||||
|
||||
@@ -181,6 +189,7 @@ describe('PiHole API client', () => {
|
||||
// Assert
|
||||
expect(summary).toBe(true);
|
||||
expect(calledCount).toBe(1);
|
||||
expect(countTriedRequests).toBe(1);
|
||||
|
||||
expect(errorLogSpy).not.toHaveBeenCalled();
|
||||
expect(warningLogSpy).not.toHaveBeenCalled();
|
||||
@@ -188,12 +197,13 @@ describe('PiHole API client', () => {
|
||||
errorLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('enable - return false when state change is not as expected', async () => {
|
||||
it('enable - return true when state change is as expected after 10 retries', async () => {
|
||||
// arrange
|
||||
const errorLogSpy = vi.spyOn(Consola, 'error');
|
||||
const warningLogSpy = vi.spyOn(Consola, 'warn');
|
||||
|
||||
let calledCount = 0;
|
||||
let countTriedRequests = 0;
|
||||
|
||||
fetchMock.mockResponse((request) => {
|
||||
if (request.url === 'http://pi.hole/admin/api.php?enable&auth=nice') {
|
||||
@@ -203,6 +213,19 @@ describe('PiHole API client', () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (request.url === 'http://pi.hole/admin/api.php?summaryRaw&auth=nice'){
|
||||
countTriedRequests += 1;
|
||||
if(countTriedRequests < 10) {
|
||||
return JSON.stringify({
|
||||
status: 'disabled',
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
status: 'enabled',
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Bad url: ${request.url}`));
|
||||
});
|
||||
|
||||
@@ -212,8 +235,9 @@ describe('PiHole API client', () => {
|
||||
const summary = await client.enable();
|
||||
|
||||
// Assert
|
||||
expect(summary).toBe(false);
|
||||
expect(summary).toBe(true);
|
||||
expect(calledCount).toBe(1);
|
||||
expect(countTriedRequests).toBe(10);
|
||||
|
||||
expect(errorLogSpy).not.toHaveBeenCalled();
|
||||
expect(warningLogSpy).not.toHaveBeenCalled();
|
||||
@@ -227,6 +251,7 @@ describe('PiHole API client', () => {
|
||||
const warningLogSpy = vi.spyOn(Consola, 'warn');
|
||||
|
||||
let calledCount = 0;
|
||||
let countTriedRequests = 0;
|
||||
|
||||
fetchMock.mockResponse((request) => {
|
||||
if (request.url === 'http://pi.hole/admin/api.php?disable&auth=nice') {
|
||||
@@ -236,6 +261,13 @@ describe('PiHole API client', () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (request.url === 'http://pi.hole/admin/api.php?summaryRaw&auth=nice'){
|
||||
countTriedRequests += 1;
|
||||
return JSON.stringify({
|
||||
status: 'disabled',
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Bad url: ${request.url}`));
|
||||
});
|
||||
|
||||
@@ -247,6 +279,7 @@ describe('PiHole API client', () => {
|
||||
// Assert
|
||||
expect(summary).toBe(true);
|
||||
expect(calledCount).toBe(1);
|
||||
expect(countTriedRequests).toBe(1);
|
||||
|
||||
expect(errorLogSpy).not.toHaveBeenCalled();
|
||||
expect(warningLogSpy).not.toHaveBeenCalled();
|
||||
@@ -254,12 +287,13 @@ describe('PiHole API client', () => {
|
||||
errorLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('disable - return false when state change is not as expected', async () => {
|
||||
it('disable - return true when state change is as expected after 10 retries', async () => {
|
||||
// arrange
|
||||
const errorLogSpy = vi.spyOn(Consola, 'error');
|
||||
const warningLogSpy = vi.spyOn(Consola, 'warn');
|
||||
|
||||
let calledCount = 0;
|
||||
let countTriedRequests = 0;
|
||||
|
||||
fetchMock.mockResponse((request) => {
|
||||
if (request.url === 'http://pi.hole/admin/api.php?disable&auth=nice') {
|
||||
@@ -269,6 +303,19 @@ describe('PiHole API client', () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (request.url === 'http://pi.hole/admin/api.php?summaryRaw&auth=nice'){
|
||||
countTriedRequests += 1;
|
||||
if(countTriedRequests < 10) {
|
||||
return JSON.stringify({
|
||||
status: 'enabled',
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
status: 'disabled',
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Bad url: ${request.url}`));
|
||||
});
|
||||
|
||||
@@ -278,8 +325,95 @@ describe('PiHole API client', () => {
|
||||
const summary = await client.disable();
|
||||
|
||||
// Assert
|
||||
expect(summary).toBe(false);
|
||||
expect(summary).toBe(true);
|
||||
expect(calledCount).toBe(1);
|
||||
expect(countTriedRequests).toBe(10);
|
||||
|
||||
expect(errorLogSpy).not.toHaveBeenCalled();
|
||||
expect(warningLogSpy).not.toHaveBeenCalled();
|
||||
|
||||
errorLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('enable - throw error when state change is not as expected', async () => {
|
||||
// arrange
|
||||
const errorLogSpy = vi.spyOn(Consola, 'error');
|
||||
const warningLogSpy = vi.spyOn(Consola, 'warn');
|
||||
|
||||
let calledCount = 0;
|
||||
let countTriedRequests = 0;
|
||||
|
||||
fetchMock.mockResponse((request) => {
|
||||
if (request.url === 'http://pi.hole/admin/api.php?enable&auth=nice') {
|
||||
calledCount += 1;
|
||||
return JSON.stringify({
|
||||
status: 'disabled',
|
||||
});
|
||||
}
|
||||
|
||||
if (request.url === 'http://pi.hole/admin/api.php?summaryRaw&auth=nice'){
|
||||
countTriedRequests += 1;
|
||||
return JSON.stringify({
|
||||
status: 'disabled',
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Bad url: ${request.url}`));
|
||||
});
|
||||
|
||||
const client = new PiHoleClient('http://pi.hole', 'nice');
|
||||
|
||||
// Act & Assert
|
||||
await expect(() => client.enable()).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
'"Although PiHole received the command, it failed to update it\'s status: [object Object]"'
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(calledCount).toBe(1);
|
||||
expect(countTriedRequests).toBe(10);
|
||||
|
||||
expect(errorLogSpy).not.toHaveBeenCalled();
|
||||
expect(warningLogSpy).not.toHaveBeenCalled();
|
||||
|
||||
errorLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('disable - throw error when state change is not as expected', async () => {
|
||||
// arrange
|
||||
const errorLogSpy = vi.spyOn(Consola, 'error');
|
||||
const warningLogSpy = vi.spyOn(Consola, 'warn');
|
||||
|
||||
let calledCount = 0;
|
||||
let countTriedRequests = 0;
|
||||
|
||||
fetchMock.mockResponse((request) => {
|
||||
if (request.url === 'http://pi.hole/admin/api.php?disable&auth=nice') {
|
||||
calledCount += 1;
|
||||
return JSON.stringify({
|
||||
status: 'enabled',
|
||||
});
|
||||
}
|
||||
|
||||
if (request.url === 'http://pi.hole/admin/api.php?summaryRaw&auth=nice') {
|
||||
countTriedRequests += 1;
|
||||
return JSON.stringify({
|
||||
status: 'enabled',
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Bad url: ${request.url}`));
|
||||
});
|
||||
|
||||
const client = new PiHoleClient('http://pi.hole', 'nice');
|
||||
|
||||
// Act & Assert
|
||||
await expect(() => client.disable()).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
'"Although PiHole received the command, it failed to update it\'s status: [object Object]"'
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(calledCount).toBe(1);
|
||||
expect(countTriedRequests).lessThanOrEqual(10);
|
||||
|
||||
expect(errorLogSpy).not.toHaveBeenCalled();
|
||||
expect(warningLogSpy).not.toHaveBeenCalled();
|
||||
|
||||
@@ -62,6 +62,18 @@ export class PiHoleClient {
|
||||
);
|
||||
}
|
||||
|
||||
return json as PiHoleApiStatusChangeResponse;
|
||||
for(let loops = 0; loops < 10; loops++){
|
||||
const summary = await this.getSummary()
|
||||
if (summary.status === action + 'd'){
|
||||
return { status: summary.status } as PiHoleApiStatusChangeResponse;
|
||||
}
|
||||
await new Promise ((resolve) => { setTimeout(resolve, 50)});
|
||||
}
|
||||
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`Although PiHole received the command, it failed to update it's status: ${json}`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ export const boardNamespaces = [
|
||||
'layout/mobile/drawer',
|
||||
'settings/common',
|
||||
'settings/general/config-changer',
|
||||
'settings/general/edit-mode-toggle',
|
||||
'settings/general/internationalization',
|
||||
'settings/general/search-engine',
|
||||
'settings/general/theme-selector',
|
||||
'settings/general/widget-positions',
|
||||
'modules/common',
|
||||
'modules/date',
|
||||
|
||||
@@ -36,6 +36,7 @@ interface AppAppearanceType {
|
||||
iconUrl: string;
|
||||
appNameStatus: "normal"|"hover"|"hidden";
|
||||
positionAppName: Property.FlexDirection;
|
||||
appNameFontSize: number;
|
||||
lineClampAppName: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -84,11 +84,11 @@ const definition = defineWidget({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return t('item.validation.length100');
|
||||
return t('item.validation.length', {shortest: "1", longest: "100"});
|
||||
},
|
||||
href: (value) => {
|
||||
if (!z.string().min(1).max(200).safeParse(value).success) {
|
||||
return t('item.validation.length200');
|
||||
return t('item.validation.length', {shortest: "1", longest: "200"});
|
||||
}
|
||||
|
||||
if (!z.string().url().safeParse(value).success) {
|
||||
@@ -102,7 +102,7 @@ const definition = defineWidget({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return t('item.validation.length400');
|
||||
return t('item.validation.length', {shortest: "1", longest: "400"});
|
||||
},
|
||||
},
|
||||
validateInputOnChange: true,
|
||||
@@ -269,6 +269,7 @@ function BookmarkWidgetTile({ widget }: BookmarkWidgetTileProps) {
|
||||
viewport:{
|
||||
//mantine being mantine again... this might break. Needed for taking 100% of widget space
|
||||
'& div[style="min-width: 100%; display: table;"]':{
|
||||
display: 'flex !important',
|
||||
height:'100%',
|
||||
},
|
||||
},
|
||||
@@ -278,14 +279,16 @@ function BookmarkWidgetTile({ widget }: BookmarkWidgetTileProps) {
|
||||
direction={ widget.properties.layout === 'vertical' ? 'column' : 'row' }
|
||||
gap="0"
|
||||
h="100%"
|
||||
w="100%"
|
||||
>
|
||||
{widget.properties.items.map((item: BookmarkItem, index) => (
|
||||
<>
|
||||
<Divider
|
||||
m="1px"
|
||||
orientation={ widget.properties.layout !== 'vertical' ? 'vertical' : 'horizontal' } //Mantine doesn't let me refactor this, I tried
|
||||
hidden={!(index > 0)}
|
||||
/>
|
||||
{index > 0 &&
|
||||
<Divider
|
||||
m="3px"
|
||||
orientation={ widget.properties.layout !== 'vertical' ? 'vertical' : 'horizontal' }
|
||||
/>
|
||||
}
|
||||
<Card
|
||||
key={index}
|
||||
px="md"
|
||||
@@ -297,7 +300,8 @@ function BookmarkWidgetTile({ widget }: BookmarkWidgetTileProps) {
|
||||
bg="transparent"
|
||||
sx={{
|
||||
'&:hover': { backgroundColor: fn.primaryColor().concat('40'),}, //'40' = 25% opacity
|
||||
flex:'1 1 auto'
|
||||
flex:'1 1 auto',
|
||||
overflow: 'unset',
|
||||
}}
|
||||
display="flex"
|
||||
>
|
||||
|
||||
@@ -22,6 +22,10 @@ const definition = defineWidget({
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
},
|
||||
showUnmonitored: {
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
},
|
||||
useSonarrv4: {
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
@@ -79,7 +83,7 @@ function CalendarTile({ widget }: CalendarTileProps) {
|
||||
configName: configName!,
|
||||
month: month.getMonth() + 1,
|
||||
year: month.getFullYear(),
|
||||
options: { useSonarrv4: widget.properties.useSonarrv4 },
|
||||
options: { useSonarrv4: widget.properties.useSonarrv4, showUnmonitored: widget.properties.showUnmonitored },
|
||||
},
|
||||
{
|
||||
staleTime: 1000 * 60 * 60 * 5,
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Badge, Box, Button, Card, Group, Image, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Image,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import { IconDeviceGamepad, IconPlayerPlay, IconPlayerStop } from '@tabler/icons-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
@@ -10,7 +21,6 @@ import { defineWidget } from '../helper';
|
||||
import { WidgetLoading } from '../loading';
|
||||
import { IWidget } from '../widgets';
|
||||
import { useDnsHoleSummeryQuery } from './DnsHoleSummary';
|
||||
import { PiholeApiSummaryType } from './type';
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'dns-hole-controls',
|
||||
@@ -31,20 +41,69 @@ interface DnsHoleControlsWidgetProps {
|
||||
widget: IDnsHoleControlsWidget;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param fetching - a expression that return a boolean if the data is been fetched
|
||||
* @param currentStatus the current status of the dns integration, either enabled or disabled
|
||||
* @returns
|
||||
*/
|
||||
const dnsLightStatus = (
|
||||
fetching: boolean,
|
||||
currentStatus: 'enabled' | 'disabled'
|
||||
): 'blue' | 'green' | 'red' => {
|
||||
if (fetching) {
|
||||
return 'blue';
|
||||
}
|
||||
if (currentStatus === 'enabled') {
|
||||
return 'green';
|
||||
}
|
||||
return 'red';
|
||||
};
|
||||
|
||||
function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
|
||||
const utils = api.useContext();
|
||||
const { data: sessionData } = useSession();
|
||||
const { isInitialLoading, data } = useDnsHoleSummeryQuery();
|
||||
const { mutateAsync } = useDnsHoleControlMutation();
|
||||
const { isInitialLoading, data, isFetching: fetchingDnsSummary } = useDnsHoleSummeryQuery();
|
||||
const { mutateAsync, isLoading: changingStatus } = useDnsHoleControlMutation();
|
||||
const { width, ref } = useElementSize();
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const { name: configName, config } = useConfigContext();
|
||||
|
||||
const trpcUtils = api.useContext();
|
||||
|
||||
if (isInitialLoading || !data || !configName) {
|
||||
return <WidgetLoading />;
|
||||
}
|
||||
|
||||
type getDnsStatusAcc = {
|
||||
enabled: string[];
|
||||
disabled: string[];
|
||||
};
|
||||
|
||||
const getDnsStatus = () => {
|
||||
const dnsList = data?.status.reduce(
|
||||
(acc: getDnsStatusAcc, dns) => {
|
||||
if (dns.status === 'enabled') {
|
||||
acc.enabled.push(dns.appId);
|
||||
} else if (dns.status === 'disabled') {
|
||||
acc.disabled.push(dns.appId);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ enabled: [], disabled: [] }
|
||||
);
|
||||
|
||||
if (dnsList.enabled.length === 0 && dnsList.disabled.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return dnsList;
|
||||
};
|
||||
|
||||
const reFetchSummaryDns = () => {
|
||||
trpcUtils.dnsHole.summary.invalidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack justify="space-between" h={'100%'} spacing="0.25rem">
|
||||
{sessionData?.user?.isAdmin && (
|
||||
@@ -59,10 +118,14 @@ function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
|
||||
await mutateAsync({
|
||||
action: 'enable',
|
||||
configName,
|
||||
appsToChange: getDnsStatus()?.disabled,
|
||||
},{
|
||||
onSettled: () => {
|
||||
reFetchSummaryDns();
|
||||
}
|
||||
});
|
||||
|
||||
await utils.dnsHole.summary.invalidate();
|
||||
}}
|
||||
disabled={getDnsStatus()?.disabled.length === 0 || fetchingDnsSummary || changingStatus}
|
||||
leftIcon={<IconPlayerPlay size={20} />}
|
||||
variant="light"
|
||||
color="green"
|
||||
@@ -75,9 +138,14 @@ function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
|
||||
await mutateAsync({
|
||||
action: 'disable',
|
||||
configName,
|
||||
appsToChange: getDnsStatus()?.enabled,
|
||||
},{
|
||||
onSettled: () => {
|
||||
reFetchSummaryDns();
|
||||
}
|
||||
});
|
||||
await utils.dnsHole.summary.invalidate();
|
||||
}}
|
||||
disabled={getDnsStatus()?.enabled.length === 0 || fetchingDnsSummary || changingStatus}
|
||||
leftIcon={<IconPlayerStop size={20} />}
|
||||
variant="light"
|
||||
color="red"
|
||||
@@ -89,15 +157,15 @@ function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
|
||||
)}
|
||||
|
||||
<Stack spacing="0.25rem">
|
||||
{data.status.map((status, index) => {
|
||||
const app = config?.apps.find((x) => x.id === status.appId);
|
||||
{data.status.map((dnsHole, index) => {
|
||||
const app = config?.apps.find((x) => x.id === dnsHole.appId);
|
||||
|
||||
if (!app) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card withBorder={true} key={index} p="xs">
|
||||
<Card withBorder={true} key={dnsHole.appId} p="xs">
|
||||
<Group>
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
@@ -112,7 +180,43 @@ function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
|
||||
</Box>
|
||||
<Stack spacing="0rem">
|
||||
<Text>{app.name}</Text>
|
||||
<StatusBadge status={status.status} />
|
||||
<UnstyledButton
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
action: dnsHole.status === 'enabled' ? 'disable' : 'enable',
|
||||
configName,
|
||||
appsToChange: [app.id],
|
||||
},{
|
||||
onSettled: () => {
|
||||
reFetchSummaryDns();
|
||||
}
|
||||
});
|
||||
}}
|
||||
disabled={fetchingDnsSummary || changingStatus}
|
||||
>
|
||||
<Badge
|
||||
variant="dot"
|
||||
color={dnsLightStatus(fetchingDnsSummary || changingStatus, dnsHole.status)}
|
||||
styles={(theme) => ({
|
||||
root: {
|
||||
'&:hover': {
|
||||
background:
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[4]
|
||||
: theme.colors.gray[2],
|
||||
},
|
||||
'&:active': {
|
||||
background:
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[5]
|
||||
: theme.colors.gray[3],
|
||||
},
|
||||
},
|
||||
})}
|
||||
>
|
||||
{t(dnsHole.status)}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
@@ -122,24 +226,6 @@ function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const StatusBadge = ({ status }: { status: PiholeApiSummaryType['status'] }) => {
|
||||
const { t } = useTranslation('common');
|
||||
if (status === 'enabled') {
|
||||
return (
|
||||
<Badge variant="dot" color="green">
|
||||
{t('enabled')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="dot" color="red">
|
||||
{t('disabled')}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const useDnsHoleControlMutation = () => api.dnsHole.control.useMutation();
|
||||
|
||||
export default definition;
|
||||
|
||||
@@ -58,8 +58,13 @@ function DnsHoleSummaryWidgetTile({ widget }: DnsHoleSummaryWidgetProps) {
|
||||
|
||||
return (
|
||||
<Container h="100%" p={0} style={constructContainerStyle(widget.properties.layout)}>
|
||||
{stats.map((item) => (
|
||||
<StatCard item={item} usePiHoleColors={widget.properties.usePiHoleColors} data={data} />
|
||||
{stats.map((item, index) => (
|
||||
<StatCard
|
||||
key={item.label ?? index}
|
||||
item={item}
|
||||
usePiHoleColors={widget.properties.usePiHoleColors}
|
||||
data={data}
|
||||
/>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
@@ -75,6 +80,7 @@ const stats = [
|
||||
{
|
||||
icon: IconPercentage,
|
||||
value: (x) => formatPercentage(x.adsBlockedTodayPercentage, 2),
|
||||
label: 'card.metrics.queriesBlockedTodayPercentage',
|
||||
color: 'rgba(255, 165, 20, 0.4)',
|
||||
},
|
||||
{
|
||||
@@ -106,7 +112,7 @@ export const useDnsHoleSummeryQuery = () => {
|
||||
configName: configName!,
|
||||
},
|
||||
{
|
||||
refetchInterval: 3 * 60 * 1000,
|
||||
staleTime: 1000 * 60 * 2,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
ActionIcon, Anchor,
|
||||
Badge,
|
||||
Card,
|
||||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
Tooltip, useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck, IconGitPullRequest, IconThumbDown, IconThumbUp } from '@tabler/icons-react';
|
||||
@@ -31,6 +32,10 @@ const definition = defineWidget({
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
},
|
||||
openInNewTab: {
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
},
|
||||
},
|
||||
component: MediaRequestListTile,
|
||||
gridstack: {
|
||||
@@ -56,7 +61,8 @@ const useMediaRequestDecisionMutation = () => {
|
||||
const utils = api.useContext();
|
||||
const { mutateAsync } = api.overseerr.decide.useMutation({
|
||||
onSuccess() {
|
||||
utils.mediaRequest.all.invalidate();
|
||||
utils.mediaRequest.allMedia.invalidate();
|
||||
utils.mediaRequest.users.invalidate();
|
||||
},
|
||||
});
|
||||
const { t } = useTranslation('modules/media-requests-list');
|
||||
@@ -94,11 +100,13 @@ const useMediaRequestDecisionMutation = () => {
|
||||
|
||||
function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
|
||||
const { t } = useTranslation('modules/media-requests-list');
|
||||
const { data, isLoading } = useMediaRequestQuery();
|
||||
const { data, isLoading } = useMediaRequestQuery(widget);
|
||||
// Use mutation to approve or deny a pending request
|
||||
const decideAsync = useMediaRequestDecisionMutation();
|
||||
const { data: sessionData } = useSession();
|
||||
|
||||
const mantineTheme = useMantineTheme();
|
||||
|
||||
if (!data || isLoading) {
|
||||
return <WidgetLoading />;
|
||||
}
|
||||
@@ -127,58 +135,57 @@ function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{countPendingApproval > 0 ? (
|
||||
<Text>{t('pending', { countPendingApproval })}</Text>
|
||||
) : (
|
||||
<Text>{t('nonePending')}</Text>
|
||||
)}
|
||||
{sortedData.map((item) => (
|
||||
<Card withBorder>
|
||||
<Flex wrap="wrap" justify="space-between" gap="md">
|
||||
<Flex gap="md">
|
||||
<Image
|
||||
src={item.posterPath}
|
||||
width={30}
|
||||
height={50}
|
||||
alt="poster"
|
||||
radius="xs"
|
||||
withPlaceholder
|
||||
/>
|
||||
<Stack spacing={0}>
|
||||
<Group spacing="xs">
|
||||
{item.airDate && <Text>{item.airDate.split('-')[0]}</Text>}
|
||||
<MediaRequestStatusBadge status={item.status} />
|
||||
</Group>
|
||||
<Text
|
||||
sx={{ cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
|
||||
lineClamp={1}
|
||||
weight="bold"
|
||||
component="a"
|
||||
href={item.href}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
<Stack justify="center">
|
||||
<Flex gap="xs">
|
||||
<ScrollArea h="100%">
|
||||
<Stack>
|
||||
{countPendingApproval > 0 ? (
|
||||
<Text>{t('pending', { countPendingApproval })}</Text>
|
||||
) : (
|
||||
<Text>{t('nonePending')}</Text>
|
||||
)}
|
||||
{sortedData.map((item) => (
|
||||
<Card radius="md" withBorder>
|
||||
<Flex wrap="wrap" justify="space-between" gap="md">
|
||||
<Flex gap="md">
|
||||
<Image
|
||||
src={item.userProfilePicture}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="requester avatar"
|
||||
radius="xl"
|
||||
src={item.posterPath}
|
||||
width={30}
|
||||
height={50}
|
||||
alt="poster"
|
||||
radius="xs"
|
||||
withPlaceholder
|
||||
/>
|
||||
<Text
|
||||
component="a"
|
||||
href={item.userLink}
|
||||
sx={{ cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
|
||||
>
|
||||
{item.userName}
|
||||
</Text>
|
||||
<Stack spacing={0}>
|
||||
<Group spacing="xs">
|
||||
{item.airDate && <Text>{item.airDate.split('-')[0]}</Text>}
|
||||
<MediaRequestStatusBadge status={item.status} />
|
||||
</Group>
|
||||
<Anchor
|
||||
href={item.href}
|
||||
target={widget.properties.openInNewTab ? "_blank" : "_self"}
|
||||
c={mantineTheme.colorScheme === 'dark' ? 'gray.3' : 'gray.8'}
|
||||
>
|
||||
{item.name}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Flex>
|
||||
<Stack justify="center">
|
||||
<Flex gap="xs">
|
||||
<Image
|
||||
src={item.userProfilePicture}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="requester avatar"
|
||||
radius="xl"
|
||||
withPlaceholder
|
||||
/>
|
||||
<Anchor
|
||||
href={item.userLink}
|
||||
target={widget.properties.openInNewTab ? "_blank" : "_self"}
|
||||
c={mantineTheme.colorScheme === 'dark' ? 'gray.3' : 'gray.8'}
|
||||
>
|
||||
{item.userName}
|
||||
</Anchor>
|
||||
</Flex>
|
||||
|
||||
{item.status === MediaRequestStatus.PendingApproval && sessionData?.user?.isAdmin && (
|
||||
<Group>
|
||||
@@ -195,47 +202,48 @@ function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
|
||||
loading: true,
|
||||
});
|
||||
|
||||
await decideAsync({
|
||||
request: item,
|
||||
isApproved: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconThumbUp />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('tooltips.decline')} withArrow withinPortal>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={async () => {
|
||||
await decideAsync({
|
||||
request: item,
|
||||
isApproved: false,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconThumbDown />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Flex>
|
||||
await decideAsync({
|
||||
request: item,
|
||||
isApproved: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconThumbUp />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('tooltips.decline')} withArrow withinPortal>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={async () => {
|
||||
await decideAsync({
|
||||
request: item,
|
||||
isApproved: false,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconThumbDown />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Flex>
|
||||
|
||||
<Image
|
||||
src={item.backdropPath}
|
||||
pos="absolute"
|
||||
w="100%"
|
||||
h="100%"
|
||||
opacity={0.1}
|
||||
top={0}
|
||||
left={0}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
<Image
|
||||
src={item.backdropPath}
|
||||
pos="absolute"
|
||||
w="100%"
|
||||
h="100%"
|
||||
opacity={0.1}
|
||||
top={0}
|
||||
left={0}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
import { Card, Flex, Stack, Text } from '@mantine/core';
|
||||
import { IconChartBar } from '@tabler/icons-react';
|
||||
import {
|
||||
Avatar,
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
Indicator,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { useElementSize } from '@mantine/hooks';
|
||||
import { IconChartBar, IconExternalLink } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { defineWidget } from '../helper';
|
||||
import { WidgetLoading } from '../loading';
|
||||
import { IWidget } from '../widgets';
|
||||
import { useMediaRequestQuery } from './media-request-query';
|
||||
import { useMediaRequestQuery, useUsersQuery } from './media-request-query';
|
||||
import { MediaRequestStatus } from './media-request-types';
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'media-requests-stats',
|
||||
icon: IconChartBar,
|
||||
options: {
|
||||
direction: {
|
||||
type: 'select',
|
||||
defaultValue: 'row' as 'row' | 'column',
|
||||
data: [
|
||||
{ value: 'row' },
|
||||
{ value: 'column' },
|
||||
],
|
||||
replaceLinksWithExternalHost: {
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
},
|
||||
openInNewTab: {
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
},
|
||||
},
|
||||
gridstack: {
|
||||
minWidth: 1,
|
||||
minHeight: 1,
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
maxWidth: 12,
|
||||
maxHeight: 12,
|
||||
},
|
||||
@@ -38,53 +49,134 @@ interface MediaRequestStatsWidgetProps {
|
||||
|
||||
function MediaRequestStatsTile({ widget }: MediaRequestStatsWidgetProps) {
|
||||
const { t } = useTranslation('modules/media-requests-stats');
|
||||
const { data, isFetching } = useMediaRequestQuery();
|
||||
const {
|
||||
data: mediaData,
|
||||
isFetching: mediaFetching,
|
||||
isLoading: mediaLoading,
|
||||
} = useMediaRequestQuery(widget);
|
||||
const {
|
||||
data: usersData,
|
||||
isFetching: usersFetching,
|
||||
isLoading: usersLoading
|
||||
} = useUsersQuery(widget);
|
||||
const { ref, height } = useElementSize();
|
||||
const { colorScheme } = useMantineTheme();
|
||||
|
||||
if (!data || isFetching) {
|
||||
return <WidgetLoading />;
|
||||
if (!mediaData || !usersData || mediaLoading || usersLoading) {
|
||||
return (
|
||||
<Stack ref={ref} h="100%">
|
||||
<WidgetLoading />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const appList: string[] = [];
|
||||
mediaData.forEach((item) => {
|
||||
if (!appList.includes(item.appId)) appList.push(item.appId);
|
||||
});
|
||||
|
||||
const baseStats: { label: string; number: number }[] = [
|
||||
{
|
||||
label: t('mediaStats.pending'),
|
||||
number: mediaData.filter((x) => x.status === MediaRequestStatus.PendingApproval).length,
|
||||
},
|
||||
{
|
||||
label: t('mediaStats.tvRequests'),
|
||||
number: mediaData.filter((x) => x.type === 'tv').length,
|
||||
},
|
||||
{
|
||||
label: t('mediaStats.movieRequests'),
|
||||
number: mediaData.filter((x) => x.type === 'movie').length,
|
||||
},
|
||||
{
|
||||
label: t('mediaStats.approved'),
|
||||
number: mediaData.filter((x) => x.status === MediaRequestStatus.Approved).length,
|
||||
},
|
||||
{
|
||||
label: t('mediaStats.totalRequests'),
|
||||
number: mediaData.length,
|
||||
},
|
||||
];
|
||||
|
||||
const users = usersData
|
||||
.sort((x, y) => (x.userRequestCount > y.userRequestCount ? -1 : 1))
|
||||
.slice(0, Math.trunc(height / 60));
|
||||
|
||||
return (
|
||||
<Flex
|
||||
w="100%"
|
||||
h="100%"
|
||||
gap="md"
|
||||
direction={ widget.properties.direction?? 'row' }
|
||||
>
|
||||
<StatCard
|
||||
number={data.filter((x) => x.status === MediaRequestStatus.PendingApproval).length}
|
||||
label={t('stats.pending')}
|
||||
/>
|
||||
<StatCard
|
||||
number={data.filter((x) => x.type === 'tv').length}
|
||||
label={t('stats.tvRequests')}
|
||||
/>
|
||||
<StatCard
|
||||
number={data.filter((x) => x.type === 'movie').length}
|
||||
label={t('stats.movieRequests')}
|
||||
/>
|
||||
<Flex h="100%" gap={0} direction="column">
|
||||
<Text mt={-5}>{t('mediaStats.title')}</Text>
|
||||
<Card py={5} px={10} radius="md" style={{ overflow: 'unset' }} withBorder>
|
||||
{baseStats.map((stat, index) => {
|
||||
return (
|
||||
<Group key={index} position="apart">
|
||||
<Text color="dimmed" align="center" size="xs">
|
||||
{stat.label}
|
||||
</Text>
|
||||
<Text align="center" size="xs">
|
||||
{stat.number}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
<Text mt={2}>{t('userStats.title')}</Text>
|
||||
<Stack ref={ref} style={{ flex: 1 }} spacing={5} p={0} sx={{ overflow: 'hidden' }}>
|
||||
{users.map((user) => {
|
||||
return (
|
||||
<Card
|
||||
key={user.id}
|
||||
p={0}
|
||||
component="a"
|
||||
href={user.userLink}
|
||||
target={widget.properties.openInNewTab ? "_blank" : "_self"}
|
||||
mah={95}
|
||||
mih={55}
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
withBorder
|
||||
>
|
||||
<Group
|
||||
spacing={5}
|
||||
px={10}
|
||||
py={5}
|
||||
align="center"
|
||||
h="100%"
|
||||
display="flex"
|
||||
style={{ flexDirection: 'row' }}
|
||||
>
|
||||
{appList.length > 1 && (
|
||||
<Tooltip.Floating
|
||||
label={user.app.charAt(0).toUpperCase() + user.app.slice(1)}
|
||||
c={colorScheme === 'light' ? 'black' : 'dark.0'}
|
||||
color={colorScheme === 'light' ? 'gray.2' : 'dark.4'}
|
||||
>
|
||||
<Indicator
|
||||
withBorder
|
||||
top={18}
|
||||
left={8}
|
||||
size={15}
|
||||
ml={-5}
|
||||
color={user.app === 'overseerr' ? '#ECB000' : '#6677CC'}
|
||||
processing={mediaFetching || usersFetching}
|
||||
children
|
||||
/>
|
||||
</Tooltip.Floating>
|
||||
)}
|
||||
<Avatar radius="xl" size={45} src={user.userProfilePicture} alt="user avatar" />
|
||||
<Stack spacing={0} style={{ flex: 1 }}>
|
||||
<Text>{user.userName}</Text>
|
||||
<Text size="xs">
|
||||
{t('userStats.requests', { number: user.userRequestCount })}
|
||||
</Text>
|
||||
</Stack>
|
||||
<IconExternalLink size={20} />
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
number: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const StatCard = ({ number, label }: StatCardProps) => {
|
||||
return (
|
||||
<Card w="100%" h="100%" withBorder style={{flex:"1 1 auto"}}>
|
||||
<Stack w="100%" h="100%" align="center" justify="center" spacing={0}>
|
||||
<Text align="center">
|
||||
{number}
|
||||
</Text>
|
||||
<Text color="dimmed" align="center" size="xs">
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default definition;
|
||||
export default definition;
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { MediaRequestListWidget } from './MediaRequestListTile';
|
||||
import { MediaRequestStatsWidget } from './MediaRequestStatsTile';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
export const useMediaRequestQuery = () => {
|
||||
export const useMediaRequestQuery = (widget: MediaRequestListWidget|MediaRequestStatsWidget) => {
|
||||
const { name: configName } = useConfigContext();
|
||||
return api.mediaRequest.all.useQuery(
|
||||
{ configName: configName! },
|
||||
return api.mediaRequest.allMedia.useQuery(
|
||||
{ configName: configName!, widget: widget },
|
||||
{
|
||||
refetchInterval: 3 * 60 * 1000,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const useUsersQuery = (widget: MediaRequestListWidget|MediaRequestStatsWidget) => {
|
||||
const { name: configName } = useConfigContext();
|
||||
return api.mediaRequest.users.useQuery(
|
||||
{ configName: configName!, widget: widget },
|
||||
{
|
||||
refetchInterval: 3 * 60 * 1000,
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export type MediaRequest = {
|
||||
userName: string;
|
||||
userProfilePicture: string;
|
||||
userLink: string;
|
||||
userRequestCount: number;
|
||||
airDate?: string;
|
||||
status: MediaRequestStatus;
|
||||
backdropPath: string;
|
||||
@@ -15,6 +16,15 @@ export type MediaRequest = {
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type Users = {
|
||||
app: string;
|
||||
id: number;
|
||||
userName: string;
|
||||
userProfilePicture: string;
|
||||
userLink: string;
|
||||
userRequestCount: number;
|
||||
};
|
||||
|
||||
export enum MediaRequestStatus {
|
||||
PendingApproval = 1,
|
||||
Approved = 2,
|
||||
|
||||
@@ -2,29 +2,27 @@ import { Card, Divider, Flex, Grid, Group, Text } from '@mantine/core';
|
||||
import { IconDeviceMobile, IconId } from '@tabler/icons-react';
|
||||
|
||||
import { GenericSessionInfo } from '~/types/api/media-server/session-info';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const DetailCollapseable = ({ session }: { session: GenericSessionInfo }) => {
|
||||
let details: { title: string; metrics: { name: string; value: string | undefined }[] }[] = [];
|
||||
const { t } = useTranslation('modules/media-server-list');
|
||||
|
||||
if (session.currentlyPlaying) {
|
||||
if (session.currentlyPlaying.metadata.video) {
|
||||
details = [
|
||||
...details,
|
||||
{
|
||||
title: t('detail.video.'),
|
||||
title: "Video",
|
||||
metrics: [
|
||||
{
|
||||
name: t('detail.video.resolution'),
|
||||
name: "Resolution",
|
||||
value: `${session.currentlyPlaying.metadata.video.width}x${session.currentlyPlaying.metadata.video.height}`,
|
||||
},
|
||||
{
|
||||
name: t('detail.video.framerate'),
|
||||
name: "Framerate",
|
||||
value: session.currentlyPlaying.metadata.video.videoFrameRate,
|
||||
},
|
||||
{
|
||||
name: t('detail.video.codec'),
|
||||
name: "Video Codec",
|
||||
value: session.currentlyPlaying.metadata.video.videoCodec,
|
||||
},
|
||||
{
|
||||
@@ -41,14 +39,14 @@ export const DetailCollapseable = ({ session }: { session: GenericSessionInfo })
|
||||
details = [
|
||||
...details,
|
||||
{
|
||||
title: t('detail.audio.audio'),
|
||||
title: "Audio",
|
||||
metrics: [
|
||||
{
|
||||
name: t('detail.audio.channels'),
|
||||
name: "Audio Channels",
|
||||
value: `${session.currentlyPlaying.metadata.audio.audioChannels}`,
|
||||
},
|
||||
{
|
||||
name: t('detail.audio.codec'),
|
||||
name: "Audio Codec",
|
||||
value: session.currentlyPlaying.metadata.audio.audioCodec,
|
||||
},
|
||||
],
|
||||
@@ -60,24 +58,24 @@ export const DetailCollapseable = ({ session }: { session: GenericSessionInfo })
|
||||
details = [
|
||||
...details,
|
||||
{
|
||||
title: t('detail.transcoding.transcoding'),
|
||||
title: "Transcoding",
|
||||
metrics: [
|
||||
{
|
||||
name: t('detail.video.resolution'),
|
||||
name: "Resolution",
|
||||
value: `${session.currentlyPlaying.metadata.transcoding.width}x${session.currentlyPlaying.metadata.transcoding.height}`,
|
||||
},
|
||||
{
|
||||
name: t('detail.transcoding.context'),
|
||||
name: "Context",
|
||||
value: session.currentlyPlaying.metadata.transcoding.context,
|
||||
},
|
||||
{
|
||||
name: t('detail.transcoding.requested'),
|
||||
name: "Hardware Encoding Requested",
|
||||
value: session.currentlyPlaying.metadata.transcoding.transcodeHwRequested
|
||||
? 'yes'
|
||||
: 'no',
|
||||
},
|
||||
{
|
||||
name: t('detail.transcoding.source'),
|
||||
name: "Source Codec",
|
||||
value:
|
||||
session.currentlyPlaying.metadata.transcoding.sourceAudioCodec ||
|
||||
session.currentlyPlaying.metadata.transcoding.sourceVideoCodec
|
||||
@@ -85,7 +83,7 @@ export const DetailCollapseable = ({ session }: { session: GenericSessionInfo })
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
name: t('detail.transcoding.target'),
|
||||
name: "Target Codec",
|
||||
value: `${session.currentlyPlaying.metadata.transcoding.videoCodec} ${session.currentlyPlaying.metadata.transcoding.audioCodec}`,
|
||||
},
|
||||
],
|
||||
@@ -99,19 +97,19 @@ export const DetailCollapseable = ({ session }: { session: GenericSessionInfo })
|
||||
<Flex justify="space-between" mb="xs">
|
||||
<Group>
|
||||
<IconId size={16} />
|
||||
<Text>{t('detail.id')}</Text>
|
||||
<Text>ID</Text>
|
||||
</Group>
|
||||
<Text>{session.id}</Text>
|
||||
</Flex>
|
||||
<Flex justify="space-between" mb="md">
|
||||
<Group>
|
||||
<IconDeviceMobile size={16} />
|
||||
<Text>{t('detail.device')}</Text>
|
||||
<Text>Device</Text>
|
||||
</Group>
|
||||
<Text>{session.sessionName}</Text>
|
||||
</Flex>
|
||||
{details.length > 0 && (
|
||||
<Divider label={t('detail.label')} labelPosition="center" mt="lg" mb="sm" />
|
||||
<Divider label={"Stats for nerds"} labelPosition="center" mt="lg" mb="sm" />
|
||||
)}
|
||||
<Grid>
|
||||
{details.map((detail, index) => (
|
||||
|
||||
@@ -13,7 +13,6 @@ import { IconAlertTriangle, IconMovie } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { AppAvatar } from '~/components/AppAvatar';
|
||||
import { useEditModeStore } from '~/components/Dashboard/Views/useEditModeStore';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useGetMediaServers } from './useGetMediaServers';
|
||||
import { defineWidget } from '../helper';
|
||||
@@ -71,7 +70,7 @@ function MediaServerTile({ widget }: MediaServerWidgetProps) {
|
||||
<Loader />
|
||||
<Stack align="center" spacing={0}>
|
||||
<Text>{t('descriptor.name')}</Text>
|
||||
<Text color="dimmed">{t('descriptor.loading')}</Text>
|
||||
<Text color="dimmed">{t('loading')}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
@@ -79,7 +78,7 @@ function MediaServerTile({ widget }: MediaServerWidgetProps) {
|
||||
|
||||
return (
|
||||
<Stack h="100%">
|
||||
<ScrollArea offsetScrollbars>
|
||||
<ScrollArea offsetScrollbars h="100%">
|
||||
<Table highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -99,7 +98,7 @@ function MediaServerTile({ widget }: MediaServerWidgetProps) {
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
|
||||
<Group position="right" mt="auto">
|
||||
<Group pos="absolute" bottom="15" right="15" mt="auto">
|
||||
<Avatar.Group>
|
||||
{data?.servers.map((server) => {
|
||||
const app = config?.apps.find((x) => x.id === server.appId);
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import { Flex, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
Icon,
|
||||
IconDeviceTv,
|
||||
IconHeadphones,
|
||||
IconMovie,
|
||||
IconQuestionMark,
|
||||
IconVideo,
|
||||
} from '@tabler/icons-react';
|
||||
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 = (): Icon => {
|
||||
const IconSelector = () => {
|
||||
switch (session.currentlyPlaying?.type) {
|
||||
case 'audio':
|
||||
return IconHeadphones;
|
||||
@@ -33,11 +30,12 @@ export const NowPlayingDisplay = ({ session }: { session: GenericSessionInfo })
|
||||
}
|
||||
};
|
||||
|
||||
const Test = Icon();
|
||||
const Icon = IconSelector();
|
||||
|
||||
return (
|
||||
<Flex wrap="nowrap" gap="sm" align="center">
|
||||
<Test size={16} />
|
||||
<Stack spacing={0} w={200}>
|
||||
<Icon size={16} />
|
||||
<Stack spacing={0}>
|
||||
<Text lineClamp={1}>{session.currentlyPlaying.name}</Text>
|
||||
|
||||
{session.currentlyPlaying.albumName ? (
|
||||
@@ -46,7 +44,7 @@ export const NowPlayingDisplay = ({ session }: { session: GenericSessionInfo })
|
||||
</Text>
|
||||
) : (
|
||||
session.currentlyPlaying.seasonName && (
|
||||
<Text lineClamp={2} color="dimmed" size="xs">
|
||||
<Text lineClamp={1} color="dimmed" size="xs">
|
||||
{session.currentlyPlaying.seasonName} - {session.currentlyPlaying.episodeName}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ActionIcon, createStyles, rem } from '@mantine/core';
|
||||
import { ActionIcon, ScrollArea } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { Link, RichTextEditor } from '@mantine/tiptap';
|
||||
import { IconArrowUp, IconEdit, IconEditOff } from '@tabler/icons-react';
|
||||
import { IconEdit, IconEditOff } from '@tabler/icons-react';
|
||||
import { BubbleMenu, useEditor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
import { useColorTheme } from '~/tools/color';
|
||||
import { api } from '~/utils/api';
|
||||
@@ -76,41 +76,29 @@ export function Editor({ widget }: { widget: INotebookWidget }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{!enabled && (
|
||||
<ActionIcon
|
||||
style={{
|
||||
zIndex: 1,
|
||||
}}
|
||||
top={7}
|
||||
right={7}
|
||||
pos="absolute"
|
||||
color={primaryColor}
|
||||
variant="light"
|
||||
size={30}
|
||||
radius={'md'}
|
||||
onClick={() => setIsEditing(handleEditToggle)}
|
||||
>
|
||||
{isEditing ? <IconEditOff size={20} /> : <IconEdit size={20} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
<RichTextEditor
|
||||
p={0}
|
||||
mt={0}
|
||||
h="100%"
|
||||
editor={editor}
|
||||
styles={(theme) => ({
|
||||
root: {
|
||||
'& .ProseMirror': {
|
||||
padding: '0 !important',
|
||||
},
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
toolbar: {
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
paddingTop: 0,
|
||||
paddingBottom: theme.spacing.md,
|
||||
backgroundColor: 'transparent',
|
||||
padding: '0.5rem',
|
||||
},
|
||||
content: {
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
backgroundColor: 'transparent',
|
||||
padding: '0.5rem',
|
||||
},
|
||||
})}
|
||||
>
|
||||
@@ -156,8 +144,27 @@ export function Editor({ widget }: { widget: INotebookWidget }) {
|
||||
</BubbleMenu>
|
||||
)}
|
||||
|
||||
<RichTextEditor.Content />
|
||||
<ScrollArea>
|
||||
<RichTextEditor.Content />
|
||||
</ScrollArea>
|
||||
</RichTextEditor>
|
||||
{!enabled && (
|
||||
<ActionIcon
|
||||
style={{
|
||||
zIndex: 1,
|
||||
}}
|
||||
top={7}
|
||||
right={7}
|
||||
pos="absolute"
|
||||
color={primaryColor}
|
||||
variant="light"
|
||||
size={30}
|
||||
radius={'md'}
|
||||
onClick={() => setIsEditing(handleEditToggle)}
|
||||
>
|
||||
{isEditing ? <IconEditOff size={20} /> : <IconEdit size={20} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user