refactor: proxy and cache user avatar images
This commit is contained in:
@@ -14,7 +14,6 @@ module.exports = {
|
||||
remotePatterns: [
|
||||
{ hostname: 'gravatar.com' },
|
||||
{ hostname: 'image.tmdb.org' },
|
||||
{ hostname: '*', protocol: 'https' },
|
||||
],
|
||||
},
|
||||
webpack(config) {
|
||||
|
||||
@@ -2775,6 +2775,15 @@ paths:
|
||||
imageCount:
|
||||
type: number
|
||||
example: 123
|
||||
avatar:
|
||||
type: object
|
||||
properties:
|
||||
size:
|
||||
type: number
|
||||
example: 123456
|
||||
imageCount:
|
||||
type: number
|
||||
example: 123
|
||||
apiCaches:
|
||||
type: array
|
||||
items:
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import clearCookies from '@server/middleware/clearcookies';
|
||||
import routes from '@server/routes';
|
||||
import avatarproxy from '@server/routes/avatarproxy';
|
||||
import imageproxy from '@server/routes/imageproxy';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import restartFlag from '@server/utils/restartFlag';
|
||||
@@ -202,6 +203,7 @@ app
|
||||
|
||||
// Do not set cookies so CDNs can cache them
|
||||
server.use('/imageproxy', clearCookies, imageproxy);
|
||||
server.use('/avatarproxy', clearCookies, avatarproxy);
|
||||
|
||||
server.get('*', (req, res) => handle(req, res));
|
||||
server.use(
|
||||
|
||||
@@ -58,7 +58,7 @@ export interface CacheItem {
|
||||
|
||||
export interface CacheResponse {
|
||||
apiCaches: CacheItem[];
|
||||
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
|
||||
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
|
||||
@@ -227,6 +227,9 @@ export const startJobs = (): void => {
|
||||
});
|
||||
// Clean TMDB image cache
|
||||
ImageProxy.clearCache('tmdb');
|
||||
|
||||
// Clean users avatar image cache
|
||||
ImageProxy.clearCache('avatar');
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -27,29 +27,37 @@ class ImageProxy {
|
||||
let deletedImages = 0;
|
||||
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||
|
||||
const files = await promises.readdir(cacheDirectory);
|
||||
try {
|
||||
const files = await promises.readdir(cacheDirectory);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(cacheDirectory, file);
|
||||
const stat = await promises.lstat(filePath);
|
||||
for (const file of files) {
|
||||
const filePath = path.join(cacheDirectory, file);
|
||||
const stat = await promises.lstat(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const imageFiles = await promises.readdir(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
const imageFiles = await promises.readdir(filePath);
|
||||
|
||||
for (const imageFile of imageFiles) {
|
||||
const [, expireAtSt] = imageFile.split('.');
|
||||
const expireAt = Number(expireAtSt);
|
||||
const now = Date.now();
|
||||
for (const imageFile of imageFiles) {
|
||||
const [, expireAtSt] = imageFile.split('.');
|
||||
const expireAt = Number(expireAtSt);
|
||||
const now = Date.now();
|
||||
|
||||
if (now > expireAt) {
|
||||
await promises.rm(path.join(filePath, imageFile));
|
||||
deletedImages += 1;
|
||||
if (now > expireAt) {
|
||||
await promises.rm(path.join(filePath), {
|
||||
recursive: true,
|
||||
});
|
||||
deletedImages += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
logger.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
|
||||
logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, {
|
||||
label: 'Image Cache',
|
||||
});
|
||||
}
|
||||
@@ -69,33 +77,49 @@ class ImageProxy {
|
||||
}
|
||||
|
||||
private static async getDirectorySize(dir: string): Promise<number> {
|
||||
const files = await promises.readdir(dir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
try {
|
||||
const files = await promises.readdir(dir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
const paths = files.map(async (file) => {
|
||||
const path = join(dir, file.name);
|
||||
const paths = files.map(async (file) => {
|
||||
const path = join(dir, file.name);
|
||||
|
||||
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
|
||||
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
|
||||
|
||||
if (file.isFile()) {
|
||||
const { size } = await promises.stat(path);
|
||||
if (file.isFile()) {
|
||||
const { size } = await promises.stat(path);
|
||||
|
||||
return size;
|
||||
return size;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (await Promise.all(paths))
|
||||
.flat(Infinity)
|
||||
.reduce((i, size) => i + size, 0);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (await Promise.all(paths))
|
||||
.flat(Infinity)
|
||||
.reduce((i, size) => i + size, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async getImageCount(dir: string) {
|
||||
const files = await promises.readdir(dir);
|
||||
try {
|
||||
const files = await promises.readdir(dir);
|
||||
|
||||
return files.length;
|
||||
return files.length;
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private fetch: typeof fetch;
|
||||
@@ -187,16 +211,25 @@ class ImageProxy {
|
||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||
const href =
|
||||
this.baseUrl +
|
||||
(this.baseUrl.endsWith('/') ? '' : '/') +
|
||||
(this.baseUrl.length > 0
|
||||
? this.baseUrl.endsWith('/')
|
||||
? ''
|
||||
: '/'
|
||||
: '') +
|
||||
(path.startsWith('/') ? path.slice(1) : path);
|
||||
const response = await this.fetch(href);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const extension = path.split('.').pop() ?? '';
|
||||
const maxAge = Number(
|
||||
const extension = (response.headers.get('content-type') ?? '').replace(
|
||||
'image/',
|
||||
''
|
||||
);
|
||||
let maxAge = Number(
|
||||
(response.headers.get('cache-control') ?? '0').split('=')[1]
|
||||
);
|
||||
|
||||
if (!maxAge) maxAge = 604800;
|
||||
const expireAt = Date.now() + maxAge * 1000;
|
||||
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
|
||||
|
||||
|
||||
31
server/routes/avatarproxy.ts
Normal file
31
server/routes/avatarproxy.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import ImageProxy from '@server/lib/imageproxy';
|
||||
import logger from '@server/logger';
|
||||
import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const avatarImageProxy = new ImageProxy('avatar', '');
|
||||
// Proxy avatar images
|
||||
router.get('/*', async (req, res) => {
|
||||
const imagePath = req.path.startsWith('/') ? req.path.slice(1) : req.path;
|
||||
try {
|
||||
const imageData = await avatarImageProxy.getImage(imagePath);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': `image/${imageData.meta.extension}`,
|
||||
'Content-Length': imageData.imageBuffer.length,
|
||||
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
|
||||
'OS-Cache-Key': imageData.meta.cacheKey,
|
||||
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
|
||||
});
|
||||
|
||||
res.end(imageData.imageBuffer);
|
||||
} catch (e) {
|
||||
logger.error('Failed to proxy avatar image', {
|
||||
imagePath,
|
||||
errorMessage: e.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -746,11 +746,13 @@ settingsRoutes.get('/cache', async (_req, res) => {
|
||||
}));
|
||||
|
||||
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
||||
const avatarImageCache = await ImageProxy.getImageStats('avatar');
|
||||
|
||||
return res.status(200).json({
|
||||
apiCaches,
|
||||
imageCache: {
|
||||
tmdb: tmdbImageCache,
|
||||
avatar: avatarImageCache,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
@@ -6,7 +7,6 @@ import { Menu, Transition } from '@headlessui/react';
|
||||
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
|
||||
import type { default as IssueCommentType } from '@server/entity/IssueComment';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
||||
@@ -88,8 +88,8 @@ const IssueComment = ({
|
||||
</Modal>
|
||||
</Transition>
|
||||
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
|
||||
<Image
|
||||
src={comment.user.avatar}
|
||||
<CachedImage
|
||||
src={`/avatarproxy/${comment.user.avatar}`}
|
||||
alt=""
|
||||
className="h-10 w-10 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||
width={40}
|
||||
|
||||
@@ -29,7 +29,6 @@ import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import getConfig from 'next/config';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
@@ -289,10 +288,10 @@ const IssueDetails = () => {
|
||||
}
|
||||
className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
|
||||
>
|
||||
<Image
|
||||
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
|
||||
src={issueData.createdBy.avatar}
|
||||
<CachedImage
|
||||
src={`/avatarproxy/${issueData.createdBy.avatar}`}
|
||||
alt=""
|
||||
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
ClockIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
|
||||
import Image from 'next/image';
|
||||
import type { LinkProps } from 'next/link';
|
||||
import Link from 'next/link';
|
||||
import { forwardRef, Fragment } from 'react';
|
||||
@@ -56,9 +56,9 @@ const UserDropdown = () => {
|
||||
className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500"
|
||||
data-testid="user-menu"
|
||||
>
|
||||
<Image
|
||||
<CachedImage
|
||||
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
||||
src={user?.avatar || ''}
|
||||
src={user && user.avatar ? `/avatarproxy/${user.avatar}` : ''}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
@@ -79,9 +79,9 @@ const UserDropdown = () => {
|
||||
<div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur">
|
||||
<div className="flex flex-col space-y-4 px-4 py-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Image
|
||||
<CachedImage
|
||||
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
||||
src={user?.avatar || ''}
|
||||
src={user && user.avatar ? `/avatarproxy/${user.avatar}` : ''}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||
import SlideOver from '@app/components/Common/SlideOver';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
@@ -27,7 +28,6 @@ import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import getConfig from 'next/config';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
@@ -355,8 +355,8 @@ const ManageSlideOver = ({
|
||||
key={`watch-user-${user.id}`}
|
||||
content={user.displayName}
|
||||
>
|
||||
<Image
|
||||
src={user.avatar}
|
||||
<CachedImage
|
||||
src={`avatarproxy/${user.avatar}`}
|
||||
alt={user.displayName}
|
||||
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||
width={32}
|
||||
@@ -516,8 +516,8 @@ const ManageSlideOver = ({
|
||||
key={`watch-user-${user.id}`}
|
||||
content={user.displayName}
|
||||
>
|
||||
<Image
|
||||
src={user.avatar}
|
||||
<CachedImage
|
||||
src={`avatarproxy/${user.avatar}`}
|
||||
alt={user.displayName}
|
||||
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||
width={32}
|
||||
|
||||
@@ -22,7 +22,6 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
@@ -116,8 +115,8 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
||||
className="group flex items-center"
|
||||
>
|
||||
<span className="avatar-sm">
|
||||
<Image
|
||||
src={requestData.requestedBy.avatar}
|
||||
<CachedImage
|
||||
src={`/avatarproxy/${requestData.requestedBy.avatar}`}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
width={20}
|
||||
@@ -390,8 +389,8 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
className="group flex items-center"
|
||||
>
|
||||
<span className="avatar-sm">
|
||||
<Image
|
||||
src={requestData.requestedBy.avatar}
|
||||
<CachedImage
|
||||
src={`/avatarproxy/${requestData.requestedBy.avatar}`}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
width={20}
|
||||
|
||||
@@ -21,7 +21,6 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
@@ -190,8 +189,8 @@ const RequestItemError = ({
|
||||
className="group flex items-center truncate"
|
||||
>
|
||||
<span className="avatar-sm ml-1.5">
|
||||
<Image
|
||||
src={requestData.requestedBy.avatar}
|
||||
<CachedImage
|
||||
src={`/avatarproxy/${requestData.requestedBy.avatar}`}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
width={20}
|
||||
@@ -249,8 +248,8 @@ const RequestItemError = ({
|
||||
className="group flex items-center truncate"
|
||||
>
|
||||
<span className="avatar-sm ml-1.5">
|
||||
<Image
|
||||
src={requestData.modifiedBy.avatar}
|
||||
<CachedImage
|
||||
src={`/avatarproxy/${requestData.modifiedBy.avatar}`}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
width={20}
|
||||
@@ -557,8 +556,8 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
className="group flex items-center truncate"
|
||||
>
|
||||
<span className="avatar-sm ml-1.5">
|
||||
<Image
|
||||
src={requestData.requestedBy.avatar}
|
||||
<CachedImage
|
||||
src={`/avatarproxy/${requestData.requestedBy.avatar}`}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
width={20}
|
||||
@@ -616,8 +615,8 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
className="group flex items-center truncate"
|
||||
>
|
||||
<span className="avatar-sm ml-1.5">
|
||||
<Image
|
||||
src={requestData.requestedBy.avatar}
|
||||
<CachedImage
|
||||
src={`/avatarproxy/${requestData.requestedBy.avatar}`}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
width={20}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
||||
import type { User } from '@app/hooks/useUser';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
@@ -14,7 +15,6 @@ import type {
|
||||
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import { hasPermission } from '@server/lib/permissions';
|
||||
import { isEqual } from 'lodash';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import Select from 'react-select';
|
||||
@@ -561,8 +561,8 @@ const AdvancedRequester = ({
|
||||
<span className="inline-block w-full rounded-md shadow-sm">
|
||||
<Listbox.Button className="focus:shadow-outline-blue relative w-full cursor-default rounded-md border border-gray-700 bg-gray-800 py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5">
|
||||
<span className="flex items-center">
|
||||
<Image
|
||||
src={selectedUser.avatar}
|
||||
<CachedImage
|
||||
src={`/avatarproxy/${selectedUser.avatar}`}
|
||||
alt=""
|
||||
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
|
||||
width={24}
|
||||
@@ -613,8 +613,8 @@ const AdvancedRequester = ({
|
||||
selected ? 'font-semibold' : 'font-normal'
|
||||
} flex items-center`}
|
||||
>
|
||||
<Image
|
||||
src={user.avatar}
|
||||
<CachedImage
|
||||
src={`/avatarproxy/${selectedUser.avatar}`}
|
||||
alt=""
|
||||
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
|
||||
width={24}
|
||||
|
||||
@@ -81,6 +81,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
|
||||
'When enabled in settings, Jellyseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
|
||||
imagecachecount: 'Images Cached',
|
||||
imagecachesize: 'Total Cache Size',
|
||||
useravatars: 'User Avatars',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -558,6 +559,19 @@ const SettingsJobs = () => {
|
||||
{formatBytes(cacheData?.imageCache.tmdb.size ?? 0)}
|
||||
</Table.TD>
|
||||
</tr>
|
||||
<tr>
|
||||
<Table.TD>
|
||||
{intl.formatMessage(messages.useravatars)} (avatar)
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
{intl.formatNumber(
|
||||
cacheData?.imageCache.avatar.imageCount ?? 0
|
||||
)}
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
{formatBytes(cacheData?.imageCache.avatar.size ?? 0)}
|
||||
</Table.TD>
|
||||
</tr>
|
||||
</Table.TBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import Alert from '@app/components/Common/Alert';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import getConfig from 'next/config';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
@@ -183,15 +183,13 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isAllUsers() ? 'bg-indigo-500' : 'bg-gray-800'
|
||||
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
||||
className={`${isAllUsers() ? 'bg-indigo-500' : 'bg-gray-800'
|
||||
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
||||
></span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isAllUsers() ? 'translate-x-5' : 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
className={`${isAllUsers() ? 'translate-x-5' : 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
></span>
|
||||
</span>
|
||||
</th>
|
||||
@@ -225,27 +223,25 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isSelectedUser(user.id)
|
||||
className={`${isSelectedUser(user.id)
|
||||
? 'bg-indigo-500'
|
||||
: 'bg-gray-800'
|
||||
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
||||
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
||||
></span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isSelectedUser(user.id)
|
||||
className={`${isSelectedUser(user.id)
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
></span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
|
||||
<div className="flex items-center">
|
||||
<Image
|
||||
<CachedImage
|
||||
className="h-10 w-10 flex-shrink-0 rounded-full"
|
||||
src={user.thumb}
|
||||
src={`/avatarproxy/${user.thumb}`}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Alert from '@app/components/Common/Alert';
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import Header from '@app/components/Common/Header';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
@@ -29,7 +30,6 @@ import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'
|
||||
import { hasPermission } from '@server/lib/permissions';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import getConfig from 'next/config';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -634,9 +634,9 @@ const UserList = () => {
|
||||
href={`/users/${user.id}`}
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
>
|
||||
<Image
|
||||
<CachedImage
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
src={user.avatar}
|
||||
src={`/avatarproxy/${user.avatar}`}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import type { User } from '@app/hooks/useUser';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
|
||||
import Image from 'next/image';
|
||||
// import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
@@ -42,9 +43,9 @@ const ProfileHeader = ({ user, isSettingsPage }: ProfileHeaderProps) => {
|
||||
<div className="flex items-end justify-items-end space-x-5">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Image
|
||||
<CachedImage
|
||||
className="h-24 w-24 rounded-full bg-gray-600 object-cover ring-1 ring-gray-700"
|
||||
src={user.avatar}
|
||||
src={`/avatarproxy/${user.avatar}`}
|
||||
alt=""
|
||||
width={96}
|
||||
height={96}
|
||||
|
||||
Reference in New Issue
Block a user