Require new debug information in github bug issue

This commit is contained in:
schroda
2026-07-22 17:37:46 +02:00
parent d4fab13e0a
commit fcc6df37ca
11 changed files with 553 additions and 94 deletions

View File

@@ -16,3 +16,5 @@ export const enhancedCleanup = (str: string): string =>
export const reverseString = (str: string, separator: string = ''): string =>
str.split(separator).reverse().join(separator);
export const indent = (str: string, level: number, char: string): string => char.repeat(level) + str;

View File

@@ -8,7 +8,7 @@
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { getErrorMessage, noOp } from '@/lib/HelperFunctions.ts';
import { copyToClipboard, getErrorMessage, noOp } from '@/lib/HelperFunctions.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { useLingui } from '@lingui/react/macro';
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
@@ -127,9 +127,8 @@ const ExtensionStoreCard = ({
)}
<CustomTooltip title={t`Copy index url`}>
<IconButton
onClick={async () => {
await navigator.clipboard.writeText(indexUrl);
makeToast(t`Copied to clipboard`, 'info');
onClick={() => {
copyToClipboard(indexUrl);
}}
color="inherit"
>

View File

@@ -27,7 +27,6 @@ import { FlexWrapButton } from '@/base/components/buttons/FlexWrapButton.tsx';
import { TrackMangaButton } from '@/features/manga/components/TrackMangaButton.tsx';
import { useManageMangaLibraryState } from '@/features/manga/hooks/useManageMangaLibraryState.tsx';
import { Metadata as BaseMetadata } from '@/base/components/texts/Metadata.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import type { MangaType, SourceType } from '@/lib/graphql/generated/graphql-base.types.ts';
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import { MANGA_STATUS_TO_TRANSLATION } from '@/features/manga/Manga.constants.ts';
@@ -56,6 +55,7 @@ import { requestManager } from '@/lib/requests/RequestManager.ts';
import { IconBrowser } from '@/assets/icons/IconBrowser.tsx';
import { IconWebView } from '@/assets/icons/IconWebView.tsx';
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
import { copyToClipboard } from '@/lib/HelperFunctions.ts';
const DetailsWrapper = styled('div')(({ theme }) => ({
display: 'flex',
@@ -240,15 +240,6 @@ export const MangaDetails = ({
const { updateLibraryState } = useManageMangaLibraryState(manga);
const copyTitle = async () => {
try {
await navigator.clipboard.writeText(manga.title);
makeToast(t`Copied to clipboard`, 'info');
} catch (e) {
defaultPromiseErrorHandler('MangaDetails::copyTitleLongPress')(e);
}
};
return (
<DetailsWrapper>
<TopContentWrapper url={Mangas.getThumbnailUrl(manga)} mangaThumbnailBackdrop={mangaThumbnailBackdrop}>
@@ -263,7 +254,7 @@ export const MangaDetails = ({
</SearchLink>
{!!navigator.clipboard && (
<CustomTooltip title={t`Copy`}>
<IconButton onClick={copyTitle} color="inherit">
<IconButton onClick={() => copyToClipboard(manga.title)} color="inherit">
<ContentCopyIcon fontSize="small" />
</IconButton>
</CustomTooltip>

View File

@@ -12,6 +12,8 @@ import { getActiveDevice } from '@/features/device/services/Device.ts';
export const extractOriginalKey = (key: string) => key.split('_').slice(-1)[0];
export const getMetadataKeyRaw = (key: string, prefixes: string[]): string => `${prefixes.join('_')}_${key}`;
/**
* Returns the key with the provided prefixes.
*
@@ -34,7 +36,7 @@ export const getMetadataKey = (key: string, prefixes: string[] = [], appPrefix:
(prefix) => prefix.toLowerCase() !== 'default',
);
return `${finalPrefix.join('_')}_${key}`;
return getMetadataKeyRaw(key, finalPrefix);
};
export const doesAppMetadataKeyExistIn = (
@@ -46,3 +48,19 @@ export const doesAppMetadataKeyExistIn = (
export const doesMetadataKeyExistIn = (meta: Metadata | undefined, key: string): boolean =>
Object.prototype.hasOwnProperty.call(meta ?? {}, key);
export const getAppMetadataFrom = (
meta: Metadata,
prefixes: string[] = [],
appPrefix: string = APP_METADATA_KEY_PREFIX,
): Metadata => {
const appMetadata: Metadata = {};
Object.entries(meta).forEach(([key, value]) => {
if (key.startsWith([appPrefix, ...prefixes].join('_'))) {
appMetadata[key] = value;
}
});
return appMetadata;
};

View File

@@ -20,7 +20,7 @@ import type {
MetadataHolderType,
MetadataKeyValuePair,
} from '@/features/metadata/Metadata.types.ts';
import { extractOriginalKey, getMetadataKey } from '@/features/metadata/Metadata.utils.ts';
import { extractOriginalKey, getAppMetadataFrom, getMetadataKey } from '@/features/metadata/Metadata.utils.ts';
import type { MangaIdInfo } from '@/features/manga/Manga.types.ts';
import type { CategoryIdInfo } from '@/features/category/Category.types.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
@@ -37,22 +37,6 @@ const getAppKeyPrefixForMigration = (migrationId: number): string => {
return appKeyPrefix?.appKeyPrefix?.newPrefix ?? APP_METADATA_KEY_PREFIX;
};
const getAppMetadataFrom = (
meta: Metadata,
prefixes: string[] = [],
appPrefix: string = APP_METADATA_KEY_PREFIX,
): Metadata => {
const appMetadata: Metadata = {};
Object.entries(meta).forEach(([key, value]) => {
if (key.startsWith([appPrefix, ...prefixes].join('_'))) {
appMetadata[key] = value;
}
});
return appMetadata;
};
const applyAppKeyPrefixMigration = (meta: Metadata, migration: IMetadataMigration): Metadata => {
const migratedMetadata: Metadata = { ...meta };

View File

@@ -20,7 +20,6 @@ import { memo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import { TypographyMaxLines } from '@/base/components/texts/TypographyMaxLines.tsx';
import { makeToast } from '@/base/utils/Toast.ts';
import type { MobileHeaderProps } from '@/features/reader/overlay/ReaderOverlay.types.ts';
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
import { AppRoutes } from '@/base/AppRoute.constants.ts';
@@ -35,6 +34,7 @@ import {
useReaderScrollbarStore,
useReaderStore,
} from '@/features/reader/stores/ReaderStore.ts';
import { copyToClipboard } from '@/lib/HelperFunctions.ts';
const DEFAULT_MANGA = { ...FALLBACK_MANGA, title: '' };
@@ -113,13 +113,7 @@ const BaseReaderOverlayHeaderMobile = ({ isVisible, ref }: MobileHeaderProps & {
>
{t`Open in WebView`}
</MenuItem>
<MenuItem
disabled={!realUrl}
onClick={async () => {
await navigator.clipboard.writeText(title);
makeToast(t`Copied to clipboard`, 'info');
}}
>
<MenuItem disabled={!realUrl} onClick={() => copyToClipboard(title)}>
{t`Share`}
</MenuItem>
</Menu>

View File

@@ -0,0 +1,448 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useLingui } from '@lingui/react/macro';
import { Collapsable } from '@/base/components/Collapsable.tsx';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import Stack from '@mui/material/Stack';
import { Metadata } from '@/base/components/texts/Metadata.tsx';
import { STABLE_EMPTY_ARRAY, STABLE_EMPTY_OBJECT } from '@/base/Base.constants.ts';
import type { ServerSettings } from '@/features/settings/Settings.types.ts';
import { epochToDate } from '@/base/utils/DateHelper.ts';
import dayjs from 'dayjs';
import { getReaderSettings, useDefaultReaderSettings } from '@/features/reader/settings/ReaderSettingsMetadata.ts';
import { getActiveDevice } from '@/features/device/services/Device.ts';
import {
READING_MODE_VALUE_TO_DISPLAY_DATA,
READING_MODE_VALUES,
} from '@/features/reader/settings/ReaderSettings.constants.tsx';
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import type { IReaderSettings, ReadingMode } from '@/features/reader/Reader.types.ts';
import { Sources } from '@/features/source/services/Sources.ts';
import omitBy from 'lodash/fp/omitBy';
import { TypographyMaxLines } from '@/base/components/texts/TypographyMaxLines.tsx';
import { indent } from '@/base/utils/Strings.ts';
import Button from '@mui/material/Button';
import { assertIsDefined } from '@/base/Asserts.ts';
import { copyToClipboard, getRenderedText } from '@/lib/HelperFunctions.ts';
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
import { EmptyView } from '@/base/components/feedback/EmptyView.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { useLocalStorage } from '@/base/hooks/useStorage.tsx';
import { MIGRATION_LOCAL_STORAGE_KEY } from '@/features/migration/Migration.constants.ts';
import type { MigrationState } from '@/features/migration/Migration.types.ts';
const PRIVACY_UNSAFE_SERVER_SETTINGS: (keyof ServerSettings)[] = [
'socksProxyUsername',
'socksProxyPassword',
'authUsername',
'authPassword',
'downloadConversions',
'serveConversions',
'databaseUrl',
'databaseUsername',
'databasePassword',
'syncYomiApiKey',
'jwtAudience',
];
const getBrowserDebugInfo = async (serverAddress: string) => {
const nav = navigator;
const serviceWorkerRegistration =
'serviceWorker' in navigator
? await navigator.serviceWorker.getRegistration().catch(() => undefined)
: undefined;
return {
page: {
protocol: window.location.protocol,
localhost: window.location.hostname.includes('localhost'),
matchesServerAddress: window.location.origin === serverAddress,
},
connection:
'connection' in nav
? {
effectiveType: (nav.connection as any).effectiveType,
downlink: (nav.connection as any).downlink,
rtt: (nav.connection as any).rtt,
saveData: (nav.connection as any).saveData,
type: (nav.connection as any).type,
}
: 'Unknown',
security: {
secureContext: window.isSecureContext,
https: window.location.protocol === 'https:',
localhost: window.location.hostname.includes('localhost'),
},
protocols: {
http: true,
https: window.location.protocol === 'https:',
websocket: typeof WebSocket !== 'undefined',
webRTC: typeof RTCPeerConnection !== 'undefined',
},
browser: {
userAgent: nav.userAgent,
language: nav.language,
languages: nav.languages,
platform: 'userAgentData' in nav ? (nav.userAgentData as any)?.platform : nav.platform,
vendor: nav.vendor,
online: nav.onLine,
},
device: {
hardwareConcurrency: nav.hardwareConcurrency ?? 'Unknown',
deviceMemory: 'deviceMemory' in nav ? `${nav.deviceMemory} GB` : 'Unknown',
maxTouchPoints: nav.maxTouchPoints,
touchSupported: 'ontouchstart' in window || nav.maxTouchPoints > 0,
},
screen: {
width: screen.width,
height: screen.height,
availWidth: screen.availWidth,
availHeight: screen.availHeight,
colorDepth: screen.colorDepth,
pixelDepth: screen.pixelDepth,
pixelRatio: window.devicePixelRatio,
orientation: screen.orientation?.type ?? 'Unknown',
},
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
locale: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
locale: Intl.DateTimeFormat().resolvedOptions().locale,
},
storage: {
localStorage: (() => {
try {
localStorage.setItem('__test', '1');
localStorage.removeItem('__test');
return true;
} catch {
return false;
}
})(),
sessionStorage: (() => {
try {
sessionStorage.setItem('__test', '1');
sessionStorage.removeItem('__test');
return true;
} catch {
return false;
}
})(),
},
features: {
serviceWorker: 'serviceWorker' in navigator,
webGL: (() => {
try {
const canvas = document.createElement('canvas');
return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'));
} catch {
return false;
}
})(),
},
pwa: {
installed:
window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone === true,
displayMode: {
browser: window.matchMedia('(display-mode: browser)').matches,
standalone: window.matchMedia('(display-mode: standalone)').matches,
minimalUi: window.matchMedia('(display-mode: minimal-ui)').matches,
fullscreen: window.matchMedia('(display-mode: fullscreen)').matches,
windowControlsOverlay: window.matchMedia('(display-mode: window-controls-overlay)').matches,
},
iosStandalone: (navigator as any).standalone ?? false,
serviceWorker: {
supported: 'serviceWorker' in navigator,
controller: !!navigator.serviceWorker?.controller,
registered: !!serviceWorkerRegistration,
active: !!serviceWorkerRegistration?.active,
waiting: !!serviceWorkerRegistration?.waiting,
installing: !!serviceWorkerRegistration?.installing,
},
beforeInstallPromptSupported: 'onbeforeinstallprompt' in window,
manifest: {
linked: !!document.querySelector('link[rel="manifest"]'),
},
},
};
};
const mapObjectToMetadata = (obj: object, tabs: number = 0) => (
<Stack>
{Object.entries(obj).map(([key, value], index, array) =>
!!value && typeof value === 'object' && !Array.isArray(value) ? (
<Fragment key={key}>
{index >= 1 && <br />}
<TypographyMaxLines component="span">{indent(key, tabs, ' ')}</TypographyMaxLines>
{mapObjectToMetadata(value, tabs + 2)}
{array[index + 1] && typeof array[index + 1]?.[1] !== 'object' && <br />}
</Fragment>
) : (
<Metadata
key={key}
title={indent(key, tabs, ' ')}
value={indent(JSON.stringify(value), 1, ' ')}
titleProps={{ component: 'span' }}
valueProps={{ component: 'span' }}
stackProps={{ sx: { display: 'inline-block' } }}
/>
),
)}
</Stack>
);
export const DebugInformation = () => {
const { t } = useLingui();
const [baseUrl] = requestManager.useBaseUrl();
const aboutRequest = requestManager.useGetAbout();
const extensionStoresRequest = requestManager.useGetExtensionStores();
const extensionsRequest = requestManager.useGetExtensionList({ variables: { condition: { isInstalled: true } } });
const sourcesRequest = requestManager.useGetSourceList();
const serverSettingsRequest = requestManager.useGetServerSettings();
const clientSettings = useMetadataServerSettings();
const defaultReaderSettings = useDefaultReaderSettings();
const [migrationState] = useLocalStorage<{ state: MigrationState }>(MIGRATION_LOCAL_STORAGE_KEY);
const contentTextRef = useRef<HTMLDivElement>(null);
const [readerSettingsByReadingMode, setReaderSettingsByReadingMode] =
useState<Record<ReadingMode, IReaderSettings>>(STABLE_EMPTY_OBJECT);
const [browserDebugInfo, setBrowserDebugInfo] =
useState<Awaited<ReturnType<typeof getBrowserDebugInfo>>>(STABLE_EMPTY_OBJECT);
const aboutServer = aboutRequest.data?.aboutServer;
const aboutWebUI = aboutRequest.data?.aboutWebUI;
const extensionStoresCount = extensionStoresRequest.data?.extensionStores.totalCount ?? 0;
const extensions = extensionsRequest.data?.extensions.nodes ?? STABLE_EMPTY_ARRAY;
const sources = sourcesRequest.data?.sources.nodes ?? STABLE_EMPTY_ARRAY;
const areFromMultipleStores = useMemo(() => Sources.areFromMultipleStores(sources), [sources]);
const enabledSourcesCount = useMemo(() => Sources.filter(sources, { enabled: true }).length, [sources]);
const nsfwSourcesCount = useMemo(() => Sources.filter(sources, { isNsfw: true }).length, [sources]);
const pinnedSourcesCount = useMemo(() => Sources.filter(sources, { pinned: true }).length, [sources]);
const activeDevice = getActiveDevice();
const debugInfo = useMemo(
() => ({
About: {
Server: {
Version: aboutServer?.version,
Channel: aboutServer?.buildType,
'Build time': aboutServer?.buildTime
? epochToDate(Number(aboutServer.buildTime)).toISOString()
: '-',
},
WebUI: {
Version: aboutWebUI?.tag,
Channel: aboutWebUI?.channel,
'Update timestamp': aboutWebUI?.updateTimestamp
? dayjs(Number(aboutWebUI.updateTimestamp)).toISOString()
: '-',
},
},
Settings: {
Server: omitBy(
(_value, key) => PRIVACY_UNSAFE_SERVER_SETTINGS.includes(key as keyof ServerSettings),
serverSettingsRequest.data?.settings ?? STABLE_EMPTY_OBJECT,
),
WebUI: {
'Active Device': activeDevice,
...clientSettings.settings,
Reader: {
Default: defaultReaderSettings.settings,
...Object.fromEntries(
READING_MODE_VALUES.map((readingMode) => [
READING_MODE_VALUE_TO_DISPLAY_DATA[readingMode].title.message ?? readingMode,
omitBy(
(value, key) =>
JSON.stringify(defaultReaderSettings.settings[key as keyof IReaderSettings]) ===
JSON.stringify(value),
readerSettingsByReadingMode[readingMode],
),
]),
),
},
},
},
'Extensions/Sources': {
'Extension stores': extensionStoresCount,
'Extensions installed': extensions.length,
'Sources from different stores': areFromMultipleStores,
'Sources enabled': enabledSourcesCount,
'Sources NSFW': nsfwSourcesCount,
'Sources pinned': pinnedSourcesCount,
'Show NSFW': clientSettings.settings.showNsfw,
'Browse languages': clientSettings.settings.browseLanguages,
},
'Migration state': migrationState?.state,
Client: browserDebugInfo,
}),
[
aboutServer,
aboutWebUI,
serverSettingsRequest.data?.settings,
activeDevice,
clientSettings.settings,
defaultReaderSettings.settings,
readerSettingsByReadingMode,
extensionStoresCount,
extensions.length,
areFromMultipleStores,
enabledSourcesCount,
nsfwSourcesCount,
pinnedSourcesCount,
browserDebugInfo,
migrationState,
],
);
useEffect(() => {
const settingsByReadingModeEntries = READING_MODE_VALUES.map(
(readingMode) =>
[
readingMode,
getReaderSettings(
'global',
{ meta: defaultReaderSettings.metadata },
defaultReaderSettings.settings,
undefined,
readingMode,
),
] satisfies [ReadingMode, IReaderSettings],
);
const settingsByReadingMode = Object.fromEntries(settingsByReadingModeEntries) as Record<
ReadingMode,
IReaderSettings
>;
setReaderSettingsByReadingMode(settingsByReadingMode);
}, [defaultReaderSettings.metadata, defaultReaderSettings.settings]);
useEffect(() => {
getBrowserDebugInfo(baseUrl)
.then(setBrowserDebugInfo)
.catch(defaultPromiseErrorHandler('DebugInformation::getBrowserDebugInfo'));
}, [baseUrl]);
const isLoading =
aboutRequest.loading ||
extensionStoresRequest.loading ||
extensionsRequest.loading ||
sourcesRequest.loading ||
serverSettingsRequest.loading ||
clientSettings.loading ||
defaultReaderSettings.loading;
if (isLoading) {
return <LoadingPlaceholder />;
}
const hasError =
aboutRequest.error ||
extensionStoresRequest.error ||
extensionsRequest.error ||
sourcesRequest.error ||
serverSettingsRequest.error ||
clientSettings.request.error ||
defaultReaderSettings.request.error;
if (hasError) {
return (
<EmptyView
message={t`Could not load data`}
retry={() => {
if (aboutRequest.error) {
aboutRequest.refetch().catch(defaultPromiseErrorHandler('DebugInformation::aboutRequest'));
}
if (extensionStoresRequest.error) {
extensionStoresRequest
.refetch()
.catch(defaultPromiseErrorHandler('DebugInformation::extensionStoresRequest'));
}
if (extensionsRequest.error) {
extensionsRequest
.refetch()
.catch(defaultPromiseErrorHandler('DebugInformation::extensionsRequest'));
}
if (sourcesRequest.error) {
sourcesRequest.refetch().catch(defaultPromiseErrorHandler('DebugInformation::sourcesRequest'));
}
if (serverSettingsRequest.error) {
serverSettingsRequest
.refetch()
.catch(defaultPromiseErrorHandler('DebugInformation::serverSettingsRequest'));
}
if (clientSettings.request.error) {
clientSettings.request
.refetch()
.catch(defaultPromiseErrorHandler('DebugInformation::clientSettings'));
}
if (defaultReaderSettings.request.error) {
defaultReaderSettings.request
.refetch()
.catch(defaultPromiseErrorHandler('DebugInformation::defaultReaderSettings'));
}
}}
/>
);
}
return (
<Collapsable
slots={{
headerWrapper: { sx: { alignItems: 'center' } },
collapse: { slotProps: { wrapperInner: { sx: { whiteSpace: 'pre' } } }, unmountOnExit: false },
}}
header={
<Stack sx={{ flexDirection: 'row', alignItems: 'center', gap: 1 }}>
{t`Show`}
{'clipboard' in navigator && (
<Button
variant="text"
size="small"
onClick={async (e) => {
e.stopPropagation();
assertIsDefined(contentTextRef.current?.innerText);
copyToClipboard(getRenderedText(contentTextRef.current, 'white-space: pre;'));
}}
>{t`Copy`}</Button>
)}
</Stack>
}
collapse={<Stack ref={contentTextRef}>{mapObjectToMetadata(debugInfo)}</Stack>}
/>
);
};

View File

@@ -22,6 +22,8 @@ import { VersionInfo } from '@/features/app-updates/components/VersionInfo.tsx';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { epochToDate } from '@/base/utils/DateHelper.ts';
import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.ts';
import { DebugInformation } from '@/features/settings/components/DebugInformation.tsx';
import Stack from '@mui/material/Stack';
export function About() {
const { t } = useLingui();
@@ -160,6 +162,17 @@ export function About() {
<ListItemText primary={t`Discord`} secondary={aboutServer.discord} />
</ListItemLink>
</List>
<List
subheader={
<ListSubheader component="div" id="about-webui-info">
{t`Debug information`}
</ListSubheader>
}
>
<Stack sx={{ px: 2, py: 1 }}>
<DebugInformation />
</Stack>
</List>
</List>
);
}

View File

@@ -993,14 +993,9 @@ msgstr ""
msgid "Convert images to different formats"
msgstr "Convert images to different formats"
#: src/features/extension/store/screens/ExtensionStores.tsx
#: src/features/manga/components/details/MangaDetails.tsx
#: src/features/reader/overlay/mobile/ReaderOverlayHeaderMobile.tsx
msgid "Copied to clipboard"
msgstr "Copied to clipboard"
#: src/features/manga/components/details/MangaDetails.tsx
#: src/features/migration/components/MigrationOptionsDialog.tsx
#: src/features/settings/components/DebugInformation.tsx
msgid "Copy"
msgstr "Copy"
@@ -1072,6 +1067,10 @@ msgstr "Could not install the extension"
msgid "Could not load categories"
msgstr "Could not load categories"
#: src/features/settings/components/DebugInformation.tsx
msgid "Could not load data"
msgstr "Could not load data"
#: src/features/settings/screens/Appearance.tsx
msgid "Could not load language"
msgstr "Could not load language"
@@ -1314,6 +1313,10 @@ msgstr "Database url"
msgid "Day"
msgstr "Day"
#: src/features/settings/screens/About.tsx
msgid "Debug information"
msgstr "Debug information"
#: src/features/reader/hotkeys/settings/components/ReaderSettingHotkey.tsx
msgid "Decrease auto scroll speed"
msgstr "Decrease auto scroll speed"
@@ -3361,6 +3364,10 @@ msgstr "Settings"
msgid "Share"
msgstr "Share"
#: src/features/settings/components/DebugInformation.tsx
msgid "Show"
msgstr "Show"
#: src/features/library/screens/LibrarySettings.tsx
msgid "Show all duplicated entries in your library"
msgstr "Show all duplicated entries in your library"

View File

@@ -8,6 +8,7 @@
import { CombinedGraphQLErrors } from '@apollo/client';
import type { ReactNode } from 'react';
import { makeToast } from '@/base/utils/Toast.ts';
export const jsonSaveParse = <T = any>(...args: Parameters<typeof JSON.parse>): T | null => {
try {
@@ -95,3 +96,37 @@ export const maybeExecuteWithDelay = (
action();
return undefined;
};
export const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
makeToast('Copied to clipboard', 'info');
} catch (e) {
makeToast('Could not copy to clipboard', 'error', getErrorMessage(e));
}
};
export const getRenderedText = (el: HTMLElement, cssText: string) => {
const clone = el.cloneNode(true) as HTMLElement;
const rect = el.getBoundingClientRect();
clone.style.cssText = `
position: absolute;
left: -99999px;
top: 0;
visibility: visible;
display: block;
height: auto;
max-height: none;
overflow: visible;
width: ${rect.width}px;
${cssText}
`;
document.body.appendChild(clone);
const text = clone.innerText;
clone.remove();
return text;
};