diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 02a528b8..9b4213a1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -36,27 +36,6 @@ body: validations: required: true -- type: input - id: suwayomi-server-version - attributes: - label: Suwayomi-Server version - description: You can find your Suwayomi-Server version in **More → About**. - placeholder: | - Example: "v1.1.1 | v1.1.1-r1701" - validations: - required: true - -- type: dropdown - id: database - attributes: - label: Used database - description: H2 is the default database - options: - - H2 - - PostgreSQL - validations: - required: true - - type: input id: server-os attributes: @@ -87,33 +66,22 @@ body: validations: required: true -- type: input - id: suwayomi-webui-version +- type: textarea + id: debug-information attributes: - label: Suwayomi-WebUI version - description: You can find your Suwayomi-WebUI version in **More → About**. - placeholder: | - Example: "r1689 STABLE | r2401 PREVIEW" - validations: - required: true - -- type: input - id: client-browser - attributes: - label: Used web browser - description: The browser which is used to open Suwayomi-WebUI - placeholder: | - Example: "Chrome 134.0.6998.118 (64-Bit) | FireFox 136.0.2 (64-Bit) | Electron v35.0.2" - validations: - required: true - -- type: input - id: client-os - attributes: - label: Client operating system - description: The system on which the Suwayomi-WebUI is running on - placeholder: | - Example: "Windows 11 Pro 24H2 | Ubuntu 24.04.2 LTS" + label: Debug information + description: | + Provide the debug information from the About page. + _Note:_ You can paste the copied debug information into the placeholder below. + value: | +
+ Click here to see debug information + + + ``` + Replace this text with the copied debug information + ``` +
validations: required: true diff --git a/src/base/utils/Strings.ts b/src/base/utils/Strings.ts index 52d4d4d7..63e7fb0b 100644 --- a/src/base/utils/Strings.ts +++ b/src/base/utils/Strings.ts @@ -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; diff --git a/src/features/extension/store/screens/ExtensionStores.tsx b/src/features/extension/store/screens/ExtensionStores.tsx index b24b13ae..c32bf202 100644 --- a/src/features/extension/store/screens/ExtensionStores.tsx +++ b/src/features/extension/store/screens/ExtensionStores.tsx @@ -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 = ({ )} { - await navigator.clipboard.writeText(indexUrl); - makeToast(t`Copied to clipboard`, 'info'); + onClick={() => { + copyToClipboard(indexUrl); }} color="inherit" > diff --git a/src/features/manga/components/details/MangaDetails.tsx b/src/features/manga/components/details/MangaDetails.tsx index d871706c..71211ecc 100644 --- a/src/features/manga/components/details/MangaDetails.tsx +++ b/src/features/manga/components/details/MangaDetails.tsx @@ -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 ( @@ -263,7 +254,7 @@ export const MangaDetails = ({ {!!navigator.clipboard && ( - + copyToClipboard(manga.title)} color="inherit"> diff --git a/src/features/metadata/Metadata.utils.ts b/src/features/metadata/Metadata.utils.ts index c49a77d7..b72ed7a1 100644 --- a/src/features/metadata/Metadata.utils.ts +++ b/src/features/metadata/Metadata.utils.ts @@ -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; +}; diff --git a/src/features/metadata/services/MetadataMigrations.ts b/src/features/metadata/services/MetadataMigrations.ts index 789ab22f..02ee07f7 100644 --- a/src/features/metadata/services/MetadataMigrations.ts +++ b/src/features/metadata/services/MetadataMigrations.ts @@ -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 }; diff --git a/src/features/reader/overlay/mobile/ReaderOverlayHeaderMobile.tsx b/src/features/reader/overlay/mobile/ReaderOverlayHeaderMobile.tsx index 8d79e2e5..a3797703 100644 --- a/src/features/reader/overlay/mobile/ReaderOverlayHeaderMobile.tsx +++ b/src/features/reader/overlay/mobile/ReaderOverlayHeaderMobile.tsx @@ -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`} - { - await navigator.clipboard.writeText(title); - makeToast(t`Copied to clipboard`, 'info'); - }} - > + copyToClipboard(title)}> {t`Share`} diff --git a/src/features/settings/components/DebugInformation.tsx b/src/features/settings/components/DebugInformation.tsx new file mode 100644 index 00000000..4e796aab --- /dev/null +++ b/src/features/settings/components/DebugInformation.tsx @@ -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) => ( + + {Object.entries(obj).map(([key, value], index, array) => + !!value && typeof value === 'object' && !Array.isArray(value) ? ( + + {index >= 1 &&
} + {indent(key, tabs, ' ')} + {mapObjectToMetadata(value, tabs + 2)} + {array[index + 1] && typeof array[index + 1]?.[1] !== 'object' &&
} +
+ ) : ( + + ), + )} +
+); + +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(null); + + const [readerSettingsByReadingMode, setReaderSettingsByReadingMode] = + useState>(STABLE_EMPTY_OBJECT); + const [browserDebugInfo, setBrowserDebugInfo] = + useState>>(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 ; + } + + const hasError = + aboutRequest.error || + extensionStoresRequest.error || + extensionsRequest.error || + sourcesRequest.error || + serverSettingsRequest.error || + clientSettings.request.error || + defaultReaderSettings.request.error; + if (hasError) { + return ( + { + 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 ( + + {t`Show`} + {'clipboard' in navigator && ( + + )} + + } + collapse={{mapObjectToMetadata(debugInfo)}} + /> + ); +}; diff --git a/src/features/settings/screens/About.tsx b/src/features/settings/screens/About.tsx index 5368c0e4..72f8ece9 100644 --- a/src/features/settings/screens/About.tsx +++ b/src/features/settings/screens/About.tsx @@ -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() { + + {t`Debug information`} + + } + > + + + + ); } diff --git a/src/i18n/locales/en.po b/src/i18n/locales/en.po index 236cda10..2c0d7e4c 100644 --- a/src/i18n/locales/en.po +++ b/src/i18n/locales/en.po @@ -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" diff --git a/src/lib/HelperFunctions.ts b/src/lib/HelperFunctions.ts index 83e615d3..395dfbea 100644 --- a/src/lib/HelperFunctions.ts +++ b/src/lib/HelperFunctions.ts @@ -8,6 +8,7 @@ import { CombinedGraphQLErrors } from '@apollo/client'; import type { ReactNode } from 'react'; +import { makeToast } from '@/base/utils/Toast.ts'; export const jsonSaveParse = (...args: Parameters): 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; +};