feat: add support for custom favicons (#4770)
* feat: add support for custom favicons * Commit updated API documentation * chore(i18n): add localization of text * fix(js): regenerate package lock file * chore(test): add favicon test * fix: max size not respected * Commit updated API documentation * fix: move favicon.ico to the static dir * fix: fix tests * fix: remove hard-coded content-type of icon * chore: update extracted translations * feat(admin): add support for resetting to default icon * chore: use a higher res default favicon * Commit updated API documentation --------- Co-authored-by: Owncast <owncast@owncast.online>
@@ -0,0 +1,243 @@
|
||||
import { Button, Upload, Popconfirm } from 'antd';
|
||||
import { RcFile } from 'antd/lib/upload/interface';
|
||||
import React, { useState, useRef, FC } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useTranslation } from 'next-export-i18n';
|
||||
import { FormStatusIndicator } from './FormStatusIndicator';
|
||||
import { RESET_TIMEOUT } from '../../utils/config-constants';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../utils/input-statuses';
|
||||
import { NEXT_PUBLIC_API_HOST } from '../../utils/apis';
|
||||
import { Localization } from '../../types/localization';
|
||||
import { Translation } from '../ui/Translation/Translation';
|
||||
|
||||
import { ACCEPTED_FAVICON_TYPES, MAX_FAVICON_FILESIZE, readableBytes } from '../../utils/images';
|
||||
|
||||
const LoadingOutlined = dynamic(() => import('@ant-design/icons/LoadingOutlined'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const UploadOutlined = dynamic(() => import('@ant-design/icons/UploadOutlined'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const UndoOutlined = dynamic(() => import('@ant-design/icons/UndoOutlined'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const ADMIN_USERNAME = process.env.NEXT_PUBLIC_ADMIN_USERNAME;
|
||||
const ADMIN_STREAMKEY = process.env.NEXT_PUBLIC_ADMIN_STREAMKEY;
|
||||
|
||||
export const EditFavicon: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [faviconCachebuster, setFaviconCacheBuster] = useState(0);
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
const pendingFile = useRef<RcFile | null>(null);
|
||||
let resetTimer = null;
|
||||
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
clearTimeout(resetTimer);
|
||||
resetTimer = null;
|
||||
};
|
||||
|
||||
// validate file type and size
|
||||
const beforeUpload = (file: RcFile) => {
|
||||
setLoading(true);
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return new Promise<void>((res, rej) => {
|
||||
if (file.size > MAX_FAVICON_FILESIZE) {
|
||||
const msg = t(Localization.Admin.StatusMessages.fileSizeTooBig, {
|
||||
size: readableBytes(file.size),
|
||||
});
|
||||
setSubmitStatus(
|
||||
createInputStatus(
|
||||
STATUS_ERROR,
|
||||
t(Localization.Admin.StatusMessages.thereWasAnError, { message: msg }),
|
||||
),
|
||||
);
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
setLoading(false);
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
return rej();
|
||||
}
|
||||
if (!ACCEPTED_FAVICON_TYPES.includes(file.type)) {
|
||||
const msg = t(Localization.Admin.StatusMessages.fileTypeNotSupported, { type: file.type });
|
||||
setSubmitStatus(
|
||||
createInputStatus(
|
||||
STATUS_ERROR,
|
||||
t(Localization.Admin.StatusMessages.thereWasAnError, { message: msg }),
|
||||
),
|
||||
);
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
setLoading(false);
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
return rej();
|
||||
}
|
||||
|
||||
pendingFile.current = file;
|
||||
setTimeout(() => res(), 100);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFaviconUpdate = async () => {
|
||||
if (!pendingFile.current) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('favicon', pendingFile.current);
|
||||
|
||||
try {
|
||||
const encoded = btoa(`${ADMIN_USERNAME}:${ADMIN_STREAMKEY}`);
|
||||
const response = await fetch(`${NEXT_PUBLIC_API_HOST}api/admin/config/favicon`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${encoded}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
|
||||
setFaviconCacheBuster(Math.floor(Math.random() * 100));
|
||||
} else {
|
||||
setSubmitStatus(
|
||||
createInputStatus(
|
||||
STATUS_ERROR,
|
||||
t(Localization.Admin.StatusMessages.thereWasAnError, { message: result.message }),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitStatus(
|
||||
createInputStatus(
|
||||
STATUS_ERROR,
|
||||
t(Localization.Admin.StatusMessages.thereWasAnError, { message: error.message }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
pendingFile.current = null;
|
||||
setLoading(false);
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
};
|
||||
|
||||
const handleResetFavicon = async () => {
|
||||
setLoading(true);
|
||||
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
|
||||
|
||||
try {
|
||||
const encoded = btoa(`${ADMIN_USERNAME}:${ADMIN_STREAMKEY}`);
|
||||
const response = await fetch(`${NEXT_PUBLIC_API_HOST}api/admin/config/favicon`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Basic ${encoded}`,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
|
||||
setFaviconCacheBuster(Math.floor(Math.random() * 100));
|
||||
} else {
|
||||
setSubmitStatus(
|
||||
createInputStatus(
|
||||
STATUS_ERROR,
|
||||
t(Localization.Admin.StatusMessages.thereWasAnError, { message: result.message }),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitStatus(
|
||||
createInputStatus(
|
||||
STATUS_ERROR,
|
||||
t(Localization.Admin.StatusMessages.thereWasAnError, { message: error.message }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
};
|
||||
|
||||
const faviconDisplayUrl = `${NEXT_PUBLIC_API_HOST}favicon.ico?random=${faviconCachebuster}`;
|
||||
|
||||
return (
|
||||
<div className="formfield-container logo-upload-container">
|
||||
<div className="label-side">
|
||||
<span className="formfield-label">
|
||||
<Translation
|
||||
translationKey={Localization.Admin.EditFavicon.label}
|
||||
defaultText="Favicon"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="input-side">
|
||||
<div className="input-group">
|
||||
<img
|
||||
src={faviconDisplayUrl}
|
||||
alt="favicon"
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
imageRendering: 'pixelated',
|
||||
marginRight: '10px',
|
||||
}}
|
||||
/>
|
||||
<Upload
|
||||
name="favicon"
|
||||
listType="picture"
|
||||
className="avatar-uploader"
|
||||
showUploadList={false}
|
||||
accept={ACCEPTED_FAVICON_TYPES.join(',')}
|
||||
beforeUpload={beforeUpload}
|
||||
customRequest={handleFaviconUpdate}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingOutlined style={{ color: 'white' }} />
|
||||
) : (
|
||||
<Button icon={<UploadOutlined />} />
|
||||
)}
|
||||
</Upload>
|
||||
<Popconfirm
|
||||
title={t(Localization.Admin.EditFavicon.resetConfirmTitle)}
|
||||
onConfirm={handleResetFavicon}
|
||||
okText={t(Localization.Admin.EditFavicon.resetConfirmOk)}
|
||||
cancelText={t(Localization.Admin.EditFavicon.resetConfirmCancel)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Button icon={<UndoOutlined />} disabled={loading} style={{ marginLeft: '8px' }}>
|
||||
<Translation
|
||||
translationKey={Localization.Admin.EditFavicon.resetButton}
|
||||
defaultText="Reset"
|
||||
/>
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
<p className="field-tip">
|
||||
<Translation
|
||||
translationKey={Localization.Admin.EditFavicon.tip}
|
||||
defaultText="Upload a custom favicon (PNG or ICO format, max 200KB). This icon appears in browser tabs and bookmarks."
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -311,7 +311,7 @@ export const MainLayout: FC<MainLayoutProps> = ({ children }) => {
|
||||
<Layout id="admin-page" className={appClass}>
|
||||
<Head>
|
||||
<title>Owncast Admin</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
{serverError?.type === 'OWNCAST_SERVICE_UNREACHABLE' && (
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { UpdateArgs } from '../../../../types/config-section';
|
||||
import { ToggleSwitch } from '../../ToggleSwitch';
|
||||
import { EditLogo } from '../../EditLogo';
|
||||
import { EditFavicon } from '../../EditFavicon';
|
||||
import FormStatusIndicator from '../../FormStatusIndicator';
|
||||
import { createInputStatus, STATUS_SUCCESS } from '../../../../utils/input-statuses';
|
||||
import { Translation } from '../../../ui/Translation/Translation';
|
||||
@@ -269,6 +270,8 @@ export default function EditInstanceDetails() {
|
||||
{/* Logo section */}
|
||||
<EditLogo />
|
||||
|
||||
<EditFavicon />
|
||||
|
||||
<ToggleSwitch
|
||||
fieldName="hideViewerCount"
|
||||
useSubmit
|
||||
|
||||
@@ -66,28 +66,9 @@ export const Main: FC = () => {
|
||||
<Head>
|
||||
{isProduction && <ServerRenderedHydration />}
|
||||
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/img/favicon/apple-icon-57x57.png" />
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/img/favicon/apple-icon-60x60.png" />
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/img/favicon/apple-icon-72x72.png" />
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/img/favicon/apple-icon-76x76.png" />
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/img/favicon/apple-icon-114x114.png" />
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/img/favicon/apple-icon-120x120.png" />
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/img/favicon/apple-icon-144x144.png" />
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/img/favicon/apple-icon-152x152.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/favicon/apple-icon-180x180.png" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="192x192"
|
||||
href="/img/favicon/android-icon-192x192.png"
|
||||
/>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon/favicon-96x96.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon/favicon-16x16.png" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="authorization_endpoint" href="/api/auth/provider/indieauth" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta name="msapplication-TileImage" content="/img/favicon/ms-icon-144x144.png" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -28,6 +28,14 @@
|
||||
"titlePlaceholder": "Your action title (required)",
|
||||
"urlPlaceholder": "https://myserver.com/action (required)"
|
||||
},
|
||||
"EditFavicon": {
|
||||
"label": "Favicon",
|
||||
"tip": "Upload a custom favicon (PNG or ICO format, max 200KB). This icon appears in browser tabs and bookmarks.",
|
||||
"resetButton": "Reset",
|
||||
"resetConfirmTitle": "Reset to default favicon?",
|
||||
"resetConfirmOk": "Yes",
|
||||
"resetConfirmCancel": "No"
|
||||
},
|
||||
"EditInstanceDetails": {
|
||||
"directoryDescription": "Increase your audience by appearing in the <a href=\"https://owncast.directory\" target=\"_blank\" rel=\"noreferrer\"><strong>Owncast Directory</strong></a>. This is an external service run by the Owncast project. <a href=\"https://owncast.online/docs/directory/?source=admin\" target=\"_blank\" rel=\"noopener noreferrer\">Learn more</a>.",
|
||||
"offlineMessageDescription": "The offline message is displayed to your page visitors when you're not streaming. Markdown is supported.",
|
||||
@@ -205,6 +213,7 @@
|
||||
"overLimit": "Over limit",
|
||||
"placeholder": "Your chat display name"
|
||||
},
|
||||
"chatDisabled": "<strong><em>Missing translation Frontend.chatDisabled: Please report</em></strong>",
|
||||
"chatOffline": "Chat is offline",
|
||||
"componentError": "Error: {{message}}",
|
||||
"helloWorld": "Hello world",
|
||||
|
||||
@@ -42,6 +42,10 @@ async function rewrites() {
|
||||
source: '/customjavascript',
|
||||
destination: 'http://localhost:8080/customjavascript', // Proxy to Backend to work around CORS.
|
||||
},
|
||||
{
|
||||
source: '/favicon.ico',
|
||||
destination: 'http://localhost:8080/favicon.ico', // Proxy to Backend to work around CORS.
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -14348,15 +14348,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/devtools-protocol": {
|
||||
"version": "0.0.1572739",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1572739.tgz",
|
||||
"integrity": "sha512-Dd0z5UXw7TJBRtlbCK/X0hePwrUm6/zxxVkwGTQU7kclkxgept8e4xdHYCDX1t59dcpRxpJNGim9ViUOjKfjNg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
|
||||
@@ -28614,6 +28605,22 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-core/node_modules/devtools-protocol": {
|
||||
"version": "0.0.1312386",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz",
|
||||
"integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/puppeteer/node_modules/devtools-protocol": {
|
||||
"version": "0.0.1312386",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz",
|
||||
"integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
|
||||
|
Before Width: | Height: | Size: 661 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -2,40 +2,8 @@
|
||||
"name": "Owncast",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/img\/favicon\/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "\/img\/favicon\/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "\/img\/favicon\/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "\/img\/favicon\/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "\/img\/favicon\/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "\/img\/favicon\/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
"src": "/favicon.ico",
|
||||
"sizes": "any"
|
||||
}
|
||||
],
|
||||
"display": "fullscreen"
|
||||
|
||||
@@ -157,6 +157,16 @@ export const Localization = {
|
||||
serverUrlRequiredForDirectory: 'Admin.EditInstanceDetails.serverUrlRequiredForDirectory',
|
||||
},
|
||||
|
||||
// EditFavicon component specific keys
|
||||
EditFavicon: {
|
||||
label: 'Admin.EditFavicon.label',
|
||||
tip: 'Admin.EditFavicon.tip',
|
||||
resetButton: 'Admin.EditFavicon.resetButton',
|
||||
resetConfirmTitle: 'Admin.EditFavicon.resetConfirmTitle',
|
||||
resetConfirmOk: 'Admin.EditFavicon.resetConfirmOk',
|
||||
resetConfirmCancel: 'Admin.EditFavicon.resetConfirmCancel',
|
||||
},
|
||||
|
||||
// VideoVariantForm component specific keys
|
||||
VideoVariantForm: {
|
||||
bitrateDisabledPassthrough: 'Admin.VideoVariantForm.bitrateDisabledPassthrough',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const MAX_IMAGE_FILESIZE = 2097152;
|
||||
export const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif'];
|
||||
|
||||
export const MAX_FAVICON_FILESIZE = 204800; // 200KB
|
||||
export const ACCEPTED_FAVICON_TYPES = ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'];
|
||||
|
||||
export function getBase64(img: File | Blob, callback: (imageUrl: string | ArrayBuffer) => void) {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', () => callback(reader.result));
|
||||
@@ -10,7 +13,7 @@ export function getBase64(img: File | Blob, callback: (imageUrl: string | ArrayB
|
||||
export function readableBytes(bytes: number): string {
|
||||
const index = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
const size = Number((bytes / Math.pow(1024, index)).toFixed(2)) * 1;
|
||||
const size = Number((bytes / 1024 ** index).toFixed(2)) * 1;
|
||||
|
||||
return `${size} ${SIZE_UNITS[index]}`;
|
||||
}
|
||||
|
||||