Compare commits

..

10 Commits

36 changed files with 1365 additions and 310 deletions

View File

@@ -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: |
<details>
<summary>Click here to see debug information</summary>
<!-- Please leave one blank line below for enabling the code block rendering. -->
```
Replace this text with the copied debug information
```
</details>
validations:
required: true

View File

@@ -8,6 +8,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
- /
### Changed
- /
### Fixed
- /
## [20260722.01] (r3355) - 2026-07-22
### Added
- (**Migration**) Add a search option to ignore outdated matches
- (**Migration**) Add a search option to ignore matches with missing chapters
- (**Migration**) Add "local source" as a possible destination source
@@ -16,8 +30,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- (**Settings/WebView**) Add setting to enable/disable WebView
- (**Settings/Sync**) Add sync settings
- (**Reader**) Add setting to keep the screen on while reading
- (**Extension**) Support installing external JARs
- (**Reader**) Add auto background color setting
- (**Extension**) Support installing external JARs
- (**Library**) Add source filter
- (**Library**) Add random sort option to library
- (**Library**) Add button to open random library entry
@@ -40,7 +54,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- (**Migration**) Show dialog on manual search selection to optionally open the search result entry instead of selecting it
- (**Migration**) Show "manual search matches" under "other matches"
- (**Source/Extension**) Rename language "All" to "Multi"
- (**Extension/Settings**) Renamed "extension repo" to "extension stores"
- (**Settings/Extension**) Renamed "extension repo" to "extension stores"
- (**Reader**) Simplify changing settings in desktop sidebar
- (**Reader**) Ignore tap zone clicks while window does not have focus
- (**Reader**) Improve preloading pages from the previous/next chapter
@@ -90,6 +104,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- (**Download**) Fix not respecting chapter list filters for downloads
- (**Download**) Fix showing outdated download info in for some chapter cards after clearing the download queue
### Translations
Feel free to translate the project on [Weblate](https://hosted.weblate.org/projects/suwayomi/suwayomi-webui/)
Thanks to everyone that contributed to the translation of this project.
#### Added
- Hebrew (by CyberMageIL)
- Greek (by Philip Prescott-Decie)
- Portuguese (by Silvery, Rodrigo Marques)
#### Updated
- Chinese (Simplified) (by 清水汐音, ZerOriSama)
- French (by Damien O'Neil, mrintrepide)
- German (by Constantin Piber)
- Spanish (by Tiago Centurion, IJCS, zeedif, Harold Marin Salazar)
- Korean (by jsyoon)
#### Removed (less than 75% translated)
- Italian (it) (71%)
- Japanese (ja) (69.7%)
- Chinese (Traditional) (zh-Hant) (74.9%)
### Contributors
Thanks to everyone that contributed to this release
@schroda, @github-actions[bot], @weblate, @cpiber, @CyberMageIL, @ONeithan, @tigce2, @imzekee0, @aizhimoran, @themaskedshell, @jburkh10, @Seanstoppable, @web-flow, @vtorres-t, @Daemonhellz, @96Angang, @matsuaa, @zeedif, @IJCS, @ZerOri, @presdec, @asqwas22, @mrintrepide, @leollo98, @Alexis-Silvery
## [20260509.01] (r3147) - 2026-05-09
### Fixed
@@ -856,7 +902,8 @@ Thanks to everyone that contributed to this release
@schroda, @jesusFx, @QuietBlade, @anvstin, @guohuageng, @plum7x, @HiyoriTUK, @aizhimoran, @JiPaix, @Yuhyeong, @a18ccms, @chancez, @rickymcmuffin, @zmmx, @alexandrejournet, @ibaraki-douji, @nitezs, @misaka10843, @Becods, @skrewde, @xconkhi9x, @cnmorocho, @Wip-Sama, @Kefir2105, @RafieHardinur, @SuperMario229, @Alexandre-P-J, @AriaMoradi, @NathanBnm, @FumoVite, @JoHena, @bandysharif, @DevCoz, @comradekingu, @Zereef, @akabhirav
[unreleased]: https://github.com/suwayomi/suwayomi-webui/compare/v20260509.01...HEAD
[unreleased]: https://github.com/suwayomi/suwayomi-webui/compare/v20260722.01...HEAD
[20260722.01]: https://github.com/suwayomi/suwayomi-webui/compare/v20260509.01...v20260722.01
[20260509.01]: https://github.com/suwayomi/suwayomi-webui/compare/v20260508.01...v20260509.01
[20260508.01]: https://github.com/suwayomi/suwayomi-webui/compare/v20251230.01...v20260508.01
[20251230.01]: https://github.com/suwayomi/suwayomi-webui/compare/v20250801.01...v20251230.01

View File

@@ -0,0 +1,49 @@
# Material You fork maintenance
This branch keeps Suwayomi behavior upstream-compatible and confines the visual fork to theme services, navigation components, manga presentation, and reader chrome.
## Update from upstream
```sh
git fetch origin
git switch codex/material-you
git rebase origin/master
pnpm test:material-you
pnpm format:check
pnpm lint
pnpm tsc
pnpm build
```
Resolve upstream conflicts by preserving server requests, GraphQL types, routing, reader state, and settings behavior. Material You additions should remain presentation-only.
## Build
Use Node 24 or newer and pnpm 11.1.2 or newer:
```sh
pnpm setup
pnpm build
```
The deployable static application is written to `build/`.
## NAS deployment
Suwayomi uses the custom WebUI directory:
```text
/vol4/1000/docker-software/manga-stack/suwayomi/webUI
```
Before each release, copy `server.conf` and the complete live `webUI/` directory into a timestamped directory below:
```text
/vol4/1000/docker-software/.integration-backups/
```
Upload `build/` to a sibling staging directory, compare file counts and hashes, then rename the staging directory to `webUI` in one operation. Keep `webUI.flavor=CUSTOM` and the automatic WebUI update interval at `0`. Recreate only the `suwayomi` service and force-refresh browser/PWA caches.
## Rollback
Stop or recreate only Suwayomi, move the failed live `webUI/` aside, restore the backed-up `webUI/` and `server.conf`, then recreate Suwayomi. Verify HTTP 200, the GraphQL `{ __typename }` query, hashed assets, and container logs before closing the rollback.

View File

@@ -8,8 +8,10 @@
"setup": "pnpm i --frozen-lockfile && pnpm setup-env-files",
"dev": "pnpm setup && vite",
"preview": "pnpm setup && vite preview",
"build": "pnpm setup && vite build",
"test": "node -e \"console.log('imagine')\"",
"build": "pnpm setup && vite build && pnpm build-revision",
"build-revision": "tsx tools/scripts/writeWebUIRevision.ts",
"test": "pnpm test:material-you",
"test:material-you": "tsx --test src/features/theme/services/*.test.ts",
"build-md5": "find build -type f | sort | xargs md5sum | awk '{ print $1 }' | tr -d '\n' | md5sum| awk '{ print $1 }' > buildZip/md5sum ",
"build-zip": "cd build && rev=$(git rev-list HEAD --count) && echo r$rev > revision && zip -9 -r ../buildZip/Suwayomi-WebUI-r$rev *",
"lint": "oxlint src tools",

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

@@ -23,6 +23,7 @@ import { TypographyMaxLines } from '@/base/components/texts/TypographyMaxLines.t
import { MANGA_COVER_ASPECT_RATIO } from '@/features/manga/Manga.constants.ts';
import { GridLayout } from '@/base/Base.types.ts';
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
import { MATERIAL_YOU_VISUALS } from '@/features/theme/services/MaterialYouVisualTokens.ts';
const BottomGradient = styled('div')({
position: 'absolute',
@@ -78,10 +79,15 @@ export const MangaGridCard = memo(
flexDirection: 'column',
m: 0.25,
outline: selected ? '4px solid' : undefined,
borderRadius: selected ? '1px' : undefined,
borderRadius: MATERIAL_YOU_VISUALS.cardRadiusCss,
outlineColor: (theme) => theme.palette.primary.main,
backgroundColor: (theme) => (selected ? theme.palette.primary.main : undefined),
transition: `transform ${MATERIAL_YOU_VISUALS.standardTransition}, filter ${MATERIAL_YOU_VISUALS.standardTransition}`,
'@media (hover: hover) and (pointer: fine)': {
'&:hover': {
transform: `translateY(${MATERIAL_YOU_VISUALS.cardHoverLift}px)`,
filter: 'drop-shadow(0 12px 18px rgba(0, 0, 0, 0.18))',
},
'&:hover .manga-option-button': {
visibility: 'visible',
pointerEvents: 'all',
@@ -100,6 +106,7 @@ export const MangaGridCard = memo(
// force standard aspect ratio of manga covers
aspectRatio: MANGA_COVER_ASPECT_RATIO,
display: 'flex',
borderRadius: MATERIAL_YOU_VISUALS.cardRadiusCss,
}}
>
<CardActionArea

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';
@@ -45,7 +44,6 @@ import type {
MangaTitleInfo,
MangaTrackRecordInfo,
} from '@/features/manga/Manga.types.ts';
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
import { CustomIconButton } from '@/base/components/buttons/CustomIconButton.tsx';
import { Sources } from '@/features/source/services/Sources.ts';
import type { SourceIdInfo } from '@/features/source/Source.types.ts';
@@ -56,12 +54,14 @@ 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';
import { MATERIAL_YOU_VISUALS } from '@/features/theme/services/MaterialYouVisualTokens.ts';
const DetailsWrapper = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
padding: theme.spacing(1),
gap: theme.spacing(3),
padding: theme.spacing(2),
[theme.breakpoints.up('md')]: {
flexBasis: '40%',
height: 'calc(100vh - 64px)',
@@ -81,6 +81,10 @@ const TopContentWrapper = ({
<Stack
sx={{
position: 'relative',
overflow: 'hidden',
borderRadius: `${MATERIAL_YOU_VISUALS.detailRadius}px`,
backgroundColor: 'background.paper',
p: { xs: 1.5, sm: 2 },
}}
>
{mangaThumbnailBackdrop && (
@@ -103,24 +107,23 @@ const TopContentWrapper = ({
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: 0.18,
filter: 'blur(24px) saturate(1.2)',
transform: 'scale(1.12)',
}}
src={url}
alt="Manga Thumbnail"
/>
<Stack
sx={{
'&::before': (theme) =>
applyStyles(mangaThumbnailBackdrop, {
position: 'absolute',
display: 'inline-block',
content: '""',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: `linear-gradient(to top, ${theme.palette.background.default}, transparent 100%, transparent 1px),linear-gradient(to right, ${theme.palette.background.default}, transparent 50%, transparent 1px),linear-gradient(to bottom, ${theme.palette.background.default}, transparent 50%, transparent 1px),linear-gradient(to left, ${theme.palette.background.default}, transparent 50%, transparent 1px)`,
backdropFilter: 'blur(4.5px) brightness(0.75)',
}),
'&::before': (theme) => ({
position: 'absolute',
display: 'inline-block',
content: '""',
inset: 0,
backgroundColor: theme.alpha(theme.palette.background.paper, 0.7),
backdropFilter: 'blur(10px)',
}),
}}
/>
</Stack>
@@ -131,19 +134,21 @@ const TopContentWrapper = ({
const ThumbnailMetadataWrapper = styled('div')(({ theme }) => ({
display: 'flex',
paddingBottom: theme.spacing(1),
gap: theme.spacing(2),
paddingBottom: theme.spacing(2),
}));
const MetadataContainer = styled('div')(({ theme }) => ({
const MetadataContainer = styled('div')({
zIndex: 1,
marginLeft: theme.spacing(1),
}));
minWidth: 0,
});
const Metadata = (props: ComponentProps<typeof BaseMetadata>) => <BaseMetadata {...props} />;
const MangaButtonsContainer = styled('div')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
flexWrap: 'wrap',
}));
const OpenSourceButton = ({ url }: { url?: string | null }) => {
@@ -240,15 +245,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 +259,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

@@ -7,7 +7,7 @@
*/
import { useTheme } from '@mui/material/styles';
import { useLayoutEffect, useState } from 'react';
import { useLayoutEffect, useRef, useState } from 'react';
import Stack from '@mui/material/Stack';
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
import Modal from '@mui/material/Modal';
@@ -23,6 +23,10 @@ import type { TAppThemeContext } from '@/features/theme/AppTheme.types.ts';
import type { ImageRequest } from '@/lib/requests/RequestManager.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { noOp } from '@/lib/HelperFunctions.ts';
import {
isDynamicColorResultCurrent,
MATERIAL_YOU_VISUALS,
} from '@/features/theme/services/MaterialYouVisualTokens.ts';
export const Thumbnail = ({
manga,
@@ -39,6 +43,8 @@ export const Thumbnail = ({
const [isImageReady, setIsImageReady] = useState(false);
const url = Mangas.getThumbnailUrl(manga);
const latestUrlRef = useRef(url);
latestUrlRef.current = url;
useLayoutEffect(() => {
if (!mangaDynamicColorSchemes) {
@@ -56,7 +62,7 @@ export const Thumbnail = ({
imageRequest = await requestManager.requestImage(url);
const image = await imageRequest.response;
if (aborted) {
if (!isDynamicColorResultCurrent(url, latestUrlRef.current, aborted)) {
return;
}
@@ -65,7 +71,7 @@ export const Thumbnail = ({
img.src = image;
img.onload = () => {
if (aborted) {
if (!isDynamicColorResultCurrent(url, latestUrlRef.current, aborted)) {
return;
}
@@ -81,27 +87,29 @@ export const Thumbnail = ({
[0, 0, 0, 255, 75],
],
}),
]).then(([palette, averageColor]) => {
if (aborted) {
return;
}
])
.then(([palette, averageColor]) => {
if (!isDynamicColorResultCurrent(url, latestUrlRef.current, aborted)) {
return;
}
if (
!palette.Vibrant ||
!palette.DarkVibrant ||
!palette.LightVibrant ||
!palette.LightMuted ||
!palette.Muted ||
!palette.DarkMuted
) {
return;
}
if (
!palette.Vibrant ||
!palette.DarkVibrant ||
!palette.LightVibrant ||
!palette.LightMuted ||
!palette.Muted ||
!palette.DarkMuted
) {
return;
}
setDynamicColor({
...palette,
average: averageColor,
} as TAppThemeContext['dynamicColor']);
});
setDynamicColor({
...palette,
average: averageColor,
} as TAppThemeContext['dynamicColor']);
})
.catch(noOp);
};
};
@@ -120,7 +128,7 @@ export const Thumbnail = ({
<Stack
sx={{
position: 'relative',
borderRadius: 1,
borderRadius: `${MATERIAL_YOU_VISUALS.cardRadius}px`,
overflow: 'hidden',
backgroundColor: 'background.paper',
width: '125px',

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

@@ -0,0 +1,16 @@
/*
* 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/.
*/
export const MATERIAL_YOU_NAVIGATION = {
railWidth: 88,
expandedWidth: 280,
bottomBarMinHeight: 80,
touchTarget: 48,
activeIndicatorRadius: 999,
landmarkLabel: 'Primary navigation',
} as const;

View File

@@ -117,9 +117,11 @@ export function DefaultNavBar() {
pt: 'env(safe-area-inset-top)',
width: `calc(100% - ${actualNavBarWidth}px)`,
zIndex: theme.zIndex.drawer,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Toolbar sx={{ position: 'relative' }}>
<Toolbar sx={{ position: 'relative', minHeight: { xs: 64, sm: 72 } }}>
{!isMobileWidth && (
<Stack
sx={{
@@ -130,7 +132,11 @@ export function DefaultNavBar() {
alignItems: 'center',
}}
>
<IconButton aria-label="open drawer" onClick={() => setIsCollapsed(false)} color="inherit">
<IconButton
aria-label="Expand navigation"
onClick={() => setIsCollapsed(false)}
color="inherit"
>
<MenuIcon />
</IconButton>
</Stack>
@@ -156,12 +162,13 @@ export function DefaultNavBar() {
</IconButton>
)}
<Typography
variant="h5"
variant="h6"
component="h1"
noWrap
sx={{
textOverflow: 'ellipsis',
flexGrow: 1,
letterSpacing: '-0.01em',
}}
>
{title}

View File

@@ -11,7 +11,6 @@ import List from '@mui/material/List';
import IconButton from '@mui/material/IconButton';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import Divider from '@mui/material/Divider';
import { styled } from '@mui/material/styles';
import { useCallback, useRef } from 'react';
import Box from '@mui/material/Box';
@@ -20,21 +19,18 @@ import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
import { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
import type { NavbarItem } from '@/features/navigation-bar/NavigationBar.types.ts';
import { NavigationBarItem } from '@/features/navigation-bar/components/NavigationBarItem.tsx';
import { MATERIAL_YOU_NAVIGATION } from '@/features/navigation-bar/MaterialYouNavigation.ts';
const DrawerHeader = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
padding: theme.spacing(0, 1),
minHeight: 72,
padding: theme.spacing(1),
// necessary for content to be below app bar
...theme.mixins.toolbar,
}));
const MIN_WIDTH_COLLAPSED = undefined;
const MAX_WIDTH_COLLAPSED = 120;
const MIN_WIDTH_EXTENDED = 240;
const MAX_WIDTH_EXTENDED = 400;
export const DesktopSideBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) => {
const { isCollapsed, setIsCollapsed, navBarWidth, setNavBarWidth } = useNavBarContext();
const getOptionForDirection = useGetOptionForDirection();
@@ -52,11 +48,15 @@ export const DesktopSideBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) =
width: navBarWidth,
'& .MuiDrawer-paper': {
zIndex: (theme) => theme.zIndex.drawer - 1,
overflowX: 'hidden',
transition: (theme) => theme.transitions.create('width'),
},
}}
slotProps={{
paper: {
ref,
component: 'nav',
'aria-label': MATERIAL_YOU_NAVIGATION.landmarkLabel,
},
}}
>
@@ -64,17 +64,17 @@ export const DesktopSideBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) =
sx={{
pt: 'env(safe-area-inset-top)',
pl: 'env(safe-area-inset-left)',
minWidth: isCollapsed ? MIN_WIDTH_COLLAPSED : MIN_WIDTH_EXTENDED,
maxWidth: isCollapsed ? MAX_WIDTH_COLLAPSED : MAX_WIDTH_EXTENDED,
width: isCollapsed ? MATERIAL_YOU_NAVIGATION.railWidth : MATERIAL_YOU_NAVIGATION.expandedWidth,
}}
>
<DrawerHeader>
<IconButton onClick={() => setIsCollapsed(true)}>
{getOptionForDirection(<ChevronLeftIcon />, <ChevronRightIcon />)}
</IconButton>
<DrawerHeader sx={{ justifyContent: isCollapsed ? 'center' : 'flex-end' }}>
{!isCollapsed && (
<IconButton aria-label="Collapse navigation" onClick={() => setIsCollapsed(true)}>
{getOptionForDirection(<ChevronLeftIcon />, <ChevronRightIcon />)}
</IconButton>
)}
</DrawerHeader>
<Divider />
<List sx={{ p: 1 }} dense={isCollapsed}>
<List sx={{ px: 1.5, py: 1, display: 'grid', gap: 0.5 }} dense={isCollapsed}>
{navBarItems.map((navBarItem) => (
<NavigationBarItem key={navBarItem.path} {...navBarItem} />
))}

View File

@@ -16,6 +16,7 @@ import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
import type { NavbarItem } from '@/features/navigation-bar/NavigationBar.types.ts';
import { NavigationBarItem } from '@/features/navigation-bar/components/NavigationBarItem.tsx';
import Stack from '@mui/material/Stack';
import { MATERIAL_YOU_NAVIGATION } from '@/features/navigation-bar/MaterialYouNavigation.ts';
export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) => {
const theme = useTheme();
@@ -30,6 +31,8 @@ export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] })
return (
<Paper
component="nav"
aria-label={MATERIAL_YOU_NAVIGATION.landmarkLabel}
ref={ref}
sx={{
position: 'fixed',
@@ -40,15 +43,18 @@ export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] })
pl: 'env(safe-area-inset-left)',
pr: 'env(safe-area-inset-right)',
zIndex: theme.zIndex.drawer - 1,
minHeight: MATERIAL_YOU_NAVIGATION.bottomBarMinHeight,
borderRadius: '28px 28px 0 0',
backgroundColor: 'background.paper',
}}
style={{
...(theme.applyStyles('dark', {
'--Paper-overlay': 'unset',
}) as CSSProperties),
}}
elevation={3}
elevation={0}
>
<Stack sx={{ flexDirection: 'row' }}>
<Stack sx={{ minHeight: MATERIAL_YOU_NAVIGATION.bottomBarMinHeight, flexDirection: 'row', px: 0.5 }}>
{navBarItems.map((item) => (
<NavigationBarItem
key={item.path}
@@ -56,7 +62,7 @@ export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] })
slots={{
listItemLink: {
sx: {
py: 1,
py: 0.75,
},
},
}}

View File

@@ -21,6 +21,7 @@ import Badge from '@mui/material/Badge';
import Typography from '@mui/material/Typography';
import { useLingui } from '@lingui/react/macro';
import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
import { MATERIAL_YOU_NAVIGATION } from '@/features/navigation-bar/MaterialYouNavigation.ts';
export const NavigationBarItem = ({
path,
@@ -49,8 +50,20 @@ export const NavigationBarItem = ({
const { listItemProps, listItemIconProps } = useMemo(
() => ({
listItemProps: isCollapsed ? { p: 0.5, display: 'flex', flexDirection: 'column' } : {},
listItemIconProps: isCollapsed ? { justifyContent: 'center' } : {},
listItemProps: isCollapsed
? { px: 0.5, py: 0.25, display: 'flex', flexDirection: 'column', gap: 0.25 }
: { px: 1.5, py: 1 },
listItemIconProps: isCollapsed
? {
width: 64,
minWidth: 64,
height: 32,
borderRadius: MATERIAL_YOU_NAVIGATION.activeIndicatorRadius,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: isActive ? 'primary.main' : 'transparent',
}
: { minWidth: MATERIAL_YOU_NAVIGATION.touchTarget },
}),
[isCollapsed],
);
@@ -59,7 +72,15 @@ export const NavigationBarItem = ({
<ListItemLink
{...slots?.listItemLink}
selected={!isCollapsed && isActive}
sx={{ p: 0, m: 0, ...slots?.listItemLink?.sx }}
aria-current={isActive ? 'page' : undefined}
sx={{
minHeight: MATERIAL_YOU_NAVIGATION.touchTarget,
p: 0,
m: 0,
borderRadius: isCollapsed ? 2 : MATERIAL_YOU_NAVIGATION.activeIndicatorRadius,
overflow: 'hidden',
...slots?.listItemLink?.sx,
}}
to={path}
>
<CustomTooltip
@@ -77,9 +98,12 @@ export const NavigationBarItem = ({
<Badge badgeContent={badgeInfo?.count} color="primary">
<Icon
sx={{
color: isActive ? 'primary.dark' : undefined,
...theme.applyStyles('dark', {
color: isActive ? 'primary.light' : undefined,
color: isCollapsed && isActive ? 'primary.contrastText' : undefined,
...(!isCollapsed && {
color: isActive ? 'primary.dark' : undefined,
...theme.applyStyles('dark', {
color: isActive ? 'primary.light' : undefined,
}),
}),
}}
/>
@@ -92,9 +116,13 @@ export const NavigationBarItem = ({
lines={1}
variant={isCollapsed ? 'caption' : undefined}
sx={{
color: isActive ? 'primary.dark' : undefined,
...theme.applyStyles('dark', {
color: isActive ? 'primary.light' : undefined,
color: isCollapsed && isActive ? 'text.primary' : undefined,
fontWeight: isActive ? 700 : 500,
...(!isCollapsed && {
color: isActive ? 'primary.dark' : undefined,
...theme.applyStyles('dark', {
color: isActive ? 'primary.light' : undefined,
}),
}),
}}
>

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,8 @@ import {
useReaderScrollbarStore,
useReaderStore,
} from '@/features/reader/stores/ReaderStore.ts';
import { copyToClipboard } from '@/lib/HelperFunctions.ts';
import { MATERIAL_YOU_VISUALS } from '@/features/theme/services/MaterialYouVisualTokens.ts';
const DEFAULT_MANGA = { ...FALLBACK_MANGA, title: '' };
@@ -61,9 +62,12 @@ const BaseReaderOverlayHeaderMobile = ({ isVisible, ref }: MobileHeaderProps & {
right: `${scrollbar.ySize}px`,
p: 2,
pt: (theme) => `max(env(safe-area-inset-top), ${theme.spacing(2)})`,
backgroundColor: (theme) => theme.alpha(theme.palette.background.paper, 0.95),
backgroundColor: (theme) =>
theme.alpha(theme.palette.background.paper, MATERIAL_YOU_VISUALS.readerChromeOpacity),
backdropFilter: 'blur(20px) saturate(1.15)',
pointerEvents: 'all',
boxShadow: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<ReaderExitButton />
@@ -113,13 +117,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

@@ -34,6 +34,7 @@ import {
useReaderSettingsStore,
useReaderStore,
} from '@/features/reader/stores/ReaderStore.ts';
import { MATERIAL_YOU_VISUALS } from '@/features/theme/services/MaterialYouVisualTokens.ts';
const useGetPreviousNavBarStaticValue = (isVisible: boolean, isStaticNav: boolean) => {
const wasNavBarStaticRef = useRef(isStaticNav);
@@ -104,6 +105,12 @@ const BaseReaderNavBarDesktop = ({
slotProps={{
paper: {
ref: (ref: HTMLDivElement | null) => setNavBarElement(ref),
sx: {
m: 1,
height: 'calc(100% - 16px)',
borderRadius: `${MATERIAL_YOU_VISUALS.detailRadius}px`,
overflow: 'hidden',
},
},
transition: {
unmountOnExit: true,
@@ -111,7 +118,7 @@ const BaseReaderNavBarDesktop = ({
}}
>
<ReaderNavContainer sx={{ backgroundColor: 'background.paper', pointerEvents: 'all' }}>
<Stack sx={{ p: 2, gap: 2, backgroundColor: 'action.hover' }}>
<Stack sx={{ p: 2, gap: 2, backgroundColor: 'action.selected' }}>
<Stack sx={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<ReaderExitButton />
<CustomTooltip title={t`Static navigation`}>

View File

@@ -24,6 +24,7 @@ import { ReaderChapterList } from '@/features/reader/overlay/navigation/componen
import { ReaderBottomBarMobileQuickSettings } from '@/features/reader/overlay/navigation/mobile/quick-settings/ReaderBottomBarMobileQuickSettings.tsx';
import { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
import { useReaderChaptersStore, useReaderScrollbarStore } from '@/features/reader/stores/ReaderStore.ts';
import { MATERIAL_YOU_VISUALS } from '@/features/theme/services/MaterialYouVisualTokens.ts';
const BaseReaderBottomBarMobile = ({
openSettings,
@@ -68,9 +69,13 @@ const BaseReaderBottomBarMobile = ({
ref={bottomBarRef}
sx={{
alignItems: 'center',
backgroundColor: (theme) => theme.alpha(theme.palette.background.paper, 0.95),
backgroundColor: (theme) =>
theme.alpha(theme.palette.background.paper, MATERIAL_YOU_VISUALS.readerChromeOpacity),
backdropFilter: 'blur(20px) saturate(1.15)',
pb: `max(${scrollbar.xSize}px, env(safe-area-inset-bottom))`,
boxShadow: 2,
borderTop: '1px solid',
borderColor: 'divider',
borderRadius: '28px 28px 0 0',
pointerEvents: 'all',
}}
>

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

@@ -0,0 +1,25 @@
/*
* 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 assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { MATERIAL_YOU_NAVIGATION } from '@/features/navigation-bar/MaterialYouNavigation.ts';
describe('Material You navigation tokens', () => {
it('uses accessible rail and bottom-bar dimensions', () => {
assert.equal(MATERIAL_YOU_NAVIGATION.railWidth, 88);
assert.equal(MATERIAL_YOU_NAVIGATION.expandedWidth, 280);
assert.equal(MATERIAL_YOU_NAVIGATION.bottomBarMinHeight, 80);
assert.ok(MATERIAL_YOU_NAVIGATION.touchTarget >= 44);
});
it('uses pill indicators and an accessible landmark label', () => {
assert.equal(MATERIAL_YOU_NAVIGATION.activeIndicatorRadius, 999);
assert.equal(MATERIAL_YOU_NAVIGATION.landmarkLabel, 'Primary navigation');
});
});

View File

@@ -0,0 +1,45 @@
/*
* 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 assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { createMaterialYouScheme, MATERIAL_YOU_STABLE_SEED } from '@/features/theme/services/MaterialYouPalette.ts';
describe('createMaterialYouScheme', () => {
it('creates a deterministic stable blue light scheme', () => {
const first = createMaterialYouScheme(MATERIAL_YOU_STABLE_SEED, 'light');
const second = createMaterialYouScheme(MATERIAL_YOU_STABLE_SEED, 'light');
assert.deepEqual(first, second);
assert.equal(first.seed, '#5B74EF');
assert.equal(first.onSurface, '#171B2C');
assert.notEqual(first.surface, first.surfaceContainer);
});
it('orders dark tonal surfaces from darkest to lightest', () => {
const scheme = createMaterialYouScheme('#8F7CF7', 'dark');
assert.deepEqual(
[scheme.surface, scheme.surfaceContainer, scheme.surfaceContainerHigh],
['#11131A', '#191B23', '#24262F'],
);
assert.equal(scheme.onSurface, '#E4E6F0');
});
it('uses true black only for the base pure-black surface', () => {
const scheme = createMaterialYouScheme('#5B74EF', 'dark', true);
assert.equal(scheme.surface, '#000000');
assert.notEqual(scheme.surfaceContainer, '#000000');
assert.notEqual(scheme.primary, scheme.onPrimary);
});
it('normalizes invalid seeds to the stable seed', () => {
assert.deepEqual(createMaterialYouScheme('not-a-color', 'dark'), createMaterialYouScheme('#5B74EF', 'dark'));
});
});

View File

@@ -0,0 +1,104 @@
/*
* 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 { hsl, parseToHsl } from 'polished';
import { coerceIn } from '@/lib/HelperFunctions.ts';
export const MATERIAL_YOU_STABLE_SEED = '#5B74EF';
export type MaterialYouScheme = {
seed: string;
primary: string;
onPrimary: string;
primaryContainer: string;
onPrimaryContainer: string;
secondary: string;
onSecondary: string;
tertiary: string;
surface: string;
surfaceContainer: string;
surfaceContainerHigh: string;
surfaceVariant: string;
onSurface: string;
onSurfaceVariant: string;
outline: string;
outlineVariant: string;
};
const toUpperHex = (color: string): string => color.toUpperCase();
const normalizeSeed = (seed: string): string => {
try {
const parsed = parseToHsl(seed);
return toUpperHex(hsl(parsed.hue, parsed.saturation, parsed.lightness));
} catch {
return MATERIAL_YOU_STABLE_SEED;
}
};
const tone = (seed: string, lightness: number, saturationDelta: number = 0): string => {
const parsed = parseToHsl(seed);
return toUpperHex(hsl(parsed.hue, coerceIn(parsed.saturation + saturationDelta, 0.25, 0.92), lightness));
};
const shiftedTone = (seed: string, hueDelta: number, lightness: number): string => {
const parsed = parseToHsl(seed);
return toUpperHex(
hsl((parsed.hue + hueDelta + 360) % 360, coerceIn(parsed.saturation * 0.58, 0.28, 0.62), lightness),
);
};
export const createMaterialYouScheme = (
requestedSeed: string,
mode: 'light' | 'dark',
pureBlack: boolean = false,
): MaterialYouScheme => {
const seed = normalizeSeed(requestedSeed);
if (mode === 'dark') {
return {
seed,
primary: tone(seed, 0.78, -0.08),
onPrimary: tone(seed, 0.2, -0.12),
primaryContainer: tone(seed, 0.3, -0.1),
onPrimaryContainer: tone(seed, 0.9, -0.14),
secondary: shiftedTone(seed, 24, 0.76),
onSecondary: shiftedTone(seed, 24, 0.2),
tertiary: shiftedTone(seed, 62, 0.78),
surface: pureBlack ? '#000000' : '#11131A',
surfaceContainer: '#191B23',
surfaceContainerHigh: '#24262F',
surfaceVariant: '#2D303A',
onSurface: '#E4E6F0',
onSurfaceVariant: '#C4C6D0',
outline: '#8E909A',
outlineVariant: '#454751',
};
}
return {
seed,
primary: tone(seed, 0.43, -0.03),
onPrimary: '#FFFFFF',
primaryContainer: tone(seed, 0.9, -0.13),
onPrimaryContainer: tone(seed, 0.2, -0.1),
secondary: shiftedTone(seed, 24, 0.42),
onSecondary: '#FFFFFF',
tertiary: shiftedTone(seed, 62, 0.43),
surface: '#F9F9FF',
surfaceContainer: '#F0F1FA',
surfaceContainerHigh: '#E8E9F2',
surfaceVariant: '#E1E2EC',
onSurface: '#171B2C',
onSurfaceVariant: '#444752',
outline: '#757781',
outlineVariant: '#C5C6D0',
};
};

View File

@@ -0,0 +1,22 @@
/*
* 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 assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { formatWebUIRevision } from '@/features/theme/services/MaterialYouRevision.ts';
describe('formatWebUIRevision', () => {
it('matches the revision format required by Suwayomi custom WebUI builds', () => {
assert.equal(formatWebUIRevision(3356), 'r3356\n');
});
it('rejects invalid git commit counts', () => {
assert.throws(() => formatWebUIRevision(0), /positive integer/);
assert.throws(() => formatWebUIRevision(4.5), /positive integer/);
});
});

View File

@@ -0,0 +1,15 @@
/*
* 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/.
*/
export const formatWebUIRevision = (commitCount: number): string => {
if (!Number.isInteger(commitCount) || commitCount < 1) {
throw new Error('WebUI revision commit count must be a positive integer');
}
return `r${commitCount}\n`;
};

View File

@@ -0,0 +1,43 @@
/*
* 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 assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { createMaterialYouScheme } from '@/features/theme/services/MaterialYouPalette.ts';
import {
createMaterialYouThemeOptions,
MATERIAL_YOU_MOTION,
} from '@/features/theme/services/MaterialYouThemeOptions.ts';
describe('createMaterialYouThemeOptions', () => {
const options = createMaterialYouThemeOptions(createMaterialYouScheme('#5B74EF', 'dark')) as any;
it('uses Material 3 geometry and readable button labels', () => {
assert.equal(options.shape.borderRadius, 20);
assert.equal(options.components.MuiCard.styleOverrides.root.borderRadius, 20);
assert.equal(options.components.MuiDialog.styleOverrides.paper.borderRadius, 28);
assert.equal(options.components.MuiButton.styleOverrides.root.borderRadius, 999);
assert.equal(options.components.MuiButton.styleOverrides.root.textTransform, 'none');
assert.equal(options.components.MuiChip.styleOverrides.root.borderRadius, 999);
assert.equal(options.components.MuiListItemButton.styleOverrides.root.borderRadius, 16);
});
it('uses tonal surfaces instead of elevated app chrome', () => {
assert.equal(options.components.MuiAppBar.defaultProps.elevation, 0);
assert.equal(options.components.MuiDrawer.styleOverrides.paper.boxShadow, 'none');
assert.equal(options.palette.background.default, '#11131A');
assert.equal(options.palette.background.paper, '#191B23');
});
it('provides focus and reduced-motion tokens', () => {
assert.equal(options.focusRing, '0 0 0 3px rgba(183, 194, 255, 0.72)');
assert.equal(MATERIAL_YOU_MOTION.standard, '180ms cubic-bezier(0.2, 0, 0, 1)');
assert.match(options.components.MuiCssBaseline.styleOverrides, /prefers-reduced-motion/);
assert.match(options.components.MuiCssBaseline.styleOverrides, /focus-visible/);
});
});

View File

@@ -0,0 +1,165 @@
/*
* 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 type { MaterialYouScheme } from '@/features/theme/services/MaterialYouPalette.ts';
export const MATERIAL_YOU_MOTION = {
standard: '180ms cubic-bezier(0.2, 0, 0, 1)',
emphasized: '320ms cubic-bezier(0.2, 0, 0, 1)',
} as const;
const FOCUS_RING = '0 0 0 3px rgba(183, 194, 255, 0.72)';
export const createMaterialYouThemeOptions = (scheme: MaterialYouScheme) => ({
focusRing: FOCUS_RING,
shape: {
borderRadius: 20,
},
palette: {
primary: {
main: scheme.primary,
contrastText: scheme.onPrimary,
},
secondary: {
main: scheme.secondary,
contrastText: scheme.onSecondary,
},
background: {
default: scheme.surface,
paper: scheme.surfaceContainer,
},
text: {
primary: scheme.onSurface,
secondary: scheme.onSurfaceVariant,
},
divider: scheme.outlineVariant,
},
typography: {
fontFamily:
'Roboto, "Noto Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", system-ui, -apple-system, sans-serif',
h1: { fontWeight: 500, letterSpacing: '-0.02em' },
h2: { fontWeight: 500, letterSpacing: '-0.015em' },
h3: { fontWeight: 500 },
h4: { fontWeight: 500 },
h5: { fontWeight: 500 },
h6: { fontWeight: 600 },
button: { fontWeight: 600, letterSpacing: '0.01em' },
},
components: {
MuiCssBaseline: {
styleOverrides: `
:root { color-scheme: light dark; }
body { background: ${scheme.surface}; }
* { scrollbar-color: ${scheme.outlineVariant} transparent; }
:where(a, button, input, textarea, select, [tabindex]):focus-visible {
outline: none;
box-shadow: ${FOCUS_RING};
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
`,
},
MuiButton: {
defaultProps: { disableElevation: true },
styleOverrides: {
root: {
minHeight: 40,
borderRadius: 999,
paddingInline: 24,
textTransform: 'none',
transition: `background-color ${MATERIAL_YOU_MOTION.standard}, transform ${MATERIAL_YOU_MOTION.standard}`,
},
},
},
MuiIconButton: {
styleOverrides: {
root: {
minWidth: 44,
minHeight: 44,
transition: `background-color ${MATERIAL_YOU_MOTION.standard}, color ${MATERIAL_YOU_MOTION.standard}`,
},
},
},
MuiCard: {
defaultProps: { elevation: 0 },
styleOverrides: {
root: {
borderRadius: 20,
backgroundImage: 'none',
},
},
},
MuiPaper: {
styleOverrides: {
root: { backgroundImage: 'none' },
rounded: { borderRadius: 20 },
},
},
MuiDialog: {
styleOverrides: {
paper: { borderRadius: 28, backgroundColor: scheme.surfaceContainerHigh },
},
},
MuiChip: {
styleOverrides: {
root: { minHeight: 32, borderRadius: 999 },
},
},
MuiOutlinedInput: {
styleOverrides: {
root: { borderRadius: 16 },
},
},
MuiListItemButton: {
styleOverrides: {
root: {
minHeight: 48,
marginBlock: 2,
borderRadius: 16,
transition: `background-color ${MATERIAL_YOU_MOTION.standard}`,
'&.Mui-selected': {
backgroundColor: scheme.primaryContainer,
color: scheme.onPrimaryContainer,
},
},
},
},
MuiAppBar: {
defaultProps: { elevation: 0, color: 'transparent' as const },
styleOverrides: {
root: { backgroundColor: scheme.surface, backgroundImage: 'none', color: scheme.onSurface },
},
},
MuiDrawer: {
styleOverrides: {
paper: {
border: 0,
boxShadow: 'none',
backgroundColor: scheme.surfaceContainer,
backgroundImage: 'none',
},
},
},
MuiTooltip: {
styleOverrides: {
tooltip: { borderRadius: 8, fontSize: '0.75rem' },
},
},
MuiSkeleton: {
styleOverrides: {
root: { borderRadius: 12, backgroundColor: scheme.surfaceVariant },
},
},
},
});

View File

@@ -0,0 +1,30 @@
/*
* 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 assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
isDynamicColorResultCurrent,
MATERIAL_YOU_VISUALS,
} from '@/features/theme/services/MaterialYouVisualTokens.ts';
describe('Material You visual tokens', () => {
it('defines lifted cards and low-distraction reader chrome', () => {
assert.equal(MATERIAL_YOU_VISUALS.cardRadius, 20);
assert.equal(MATERIAL_YOU_VISUALS.cardRadiusCss, '20px');
assert.equal(MATERIAL_YOU_VISUALS.cardHoverLift, -4);
assert.equal(MATERIAL_YOU_VISUALS.detailRadius, 24);
assert.equal(MATERIAL_YOU_VISUALS.readerChromeOpacity, 0.92);
});
it('rejects stale or aborted dynamic-color work', () => {
assert.equal(isDynamicColorResultCurrent('/cover/1', '/cover/1', false), true);
assert.equal(isDynamicColorResultCurrent('/cover/1', '/cover/2', false), false);
assert.equal(isDynamicColorResultCurrent('/cover/1', '/cover/1', true), false);
});
});

View File

@@ -0,0 +1,19 @@
/*
* 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/.
*/
export const MATERIAL_YOU_VISUALS = {
cardRadius: 20,
cardRadiusCss: '20px',
cardHoverLift: -4,
detailRadius: 24,
readerChromeOpacity: 0.92,
standardTransition: '180ms cubic-bezier(0.2, 0, 0, 1)',
} as const;
export const isDynamicColorResultCurrent = (requestedUrl: string, currentUrl: string, aborted: boolean): boolean =>
!aborted && requestedUrl === currentUrl;

View File

@@ -6,104 +6,23 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import type { Direction, PaletteBackgroundChannel, Theme, TypeBackground } from '@mui/material/styles';
import type { Direction, Theme } from '@mui/material/styles';
import { createTheme as createMuiTheme, responsiveFontSizes, useTheme } from '@mui/material/styles';
import { useCallback } from 'react';
// oxlint-disable-next-line no-restricted-imports
import { deepmerge } from '@mui/utils';
import { complement, hsl, parseToHsl } from 'polished';
import type { HslaColor, HslColor } from 'polished/lib/types/color';
import type { AppTheme } from '@/features/theme/services/AppThemes.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
import type { TAppThemeContext } from '@/features/theme/AppTheme.types.ts';
import { ThemeMode } from '@/features/theme/AppTheme.types.ts';
import { ThemeFontLoader } from '@/features/theme/services/ThemeFontLoader.ts';
import { coerceIn } from '@/lib/HelperFunctions.ts';
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
import { createMaterialYouScheme, MATERIAL_YOU_STABLE_SEED } from '@/features/theme/services/MaterialYouPalette.ts';
import { createMaterialYouThemeOptions } from '@/features/theme/services/MaterialYouThemeOptions.ts';
const SCROLLBAR_SIZE = 14;
const isNeutralColor = (color: HslColor | HslaColor): boolean => {
const isDesaturated = color.saturation < 0.08;
const isNearBlack = color.lightness < 0.03;
const isNearWhite = color.lightness > 0.97;
return isDesaturated || isNearBlack || isNearWhite;
};
const createBackgroundColors = (color: string, lightnessPaper: number, lightnessDefault: number): TypeBackground => {
const colorHsl = parseToHsl(color);
const saturationBoost = isNeutralColor(colorHsl) ? 0 : 0.15;
const saturation = coerceIn(colorHsl.saturation + saturationBoost, 0, 1);
return {
paper: hsl(colorHsl.hue, saturation, lightnessPaper),
default: hsl(colorHsl.hue, saturation, lightnessDefault),
};
};
const getBackgroundColor = (
type: 'light' | 'dark',
appTheme: AppTheme['muiTheme'],
theme: Theme,
setPureBlackMode: boolean = false,
): (Partial<TypeBackground> & Partial<PaletteBackgroundChannel>) | undefined => {
if (setPureBlackMode) {
return {
paper: '#000',
default: '#000',
};
}
if (type === 'light' && !!theme.colorSchemes.light) {
if (typeof appTheme.colorSchemes?.light === 'object' && appTheme.colorSchemes.light.palette?.background) {
return appTheme.colorSchemes.light.palette.background;
}
return createBackgroundColors(theme.colorSchemes.light.palette.primary.dark, 0.87, 0.93);
}
if (type === 'dark' && !!theme.colorSchemes.dark) {
if (typeof appTheme.colorSchemes?.dark === 'object' && appTheme.colorSchemes.dark.palette?.background) {
return appTheme.colorSchemes.dark.palette.background;
}
return createBackgroundColors(theme.colorSchemes.dark.palette.primary.dark, 0.06, 0.03);
}
return undefined;
};
const createAppThemeWithDynamicColors = (
primaryColorDark: string | null | undefined,
primaryColorLight: string | null | undefined,
appTheme: AppTheme['muiTheme'],
): AppTheme['muiTheme'] => {
if (!primaryColorDark || !primaryColorLight) {
return appTheme;
}
return {
...appTheme,
colorSchemes: {
light: {
palette: {
primary: { main: primaryColorLight },
secondary: { main: complement(primaryColorLight) },
},
},
dark: {
palette: {
primary: { main: primaryColorDark },
secondary: { main: complement(primaryColorDark) },
},
},
},
} satisfies AppTheme['muiTheme'];
};
const getVibrantColorForTheme = (
palette: TAppThemeContext['dynamicColor'] | null,
mode: Exclude<ThemeMode, ThemeMode.SYSTEM>,
@@ -115,57 +34,42 @@ const getVibrantColorForTheme = (
return mode === ThemeMode.LIGHT ? palette.DarkVibrant.hex : palette.LightVibrant.hex;
};
const getThemeSeed = (appTheme: AppTheme['muiTheme'], mode: Exclude<ThemeMode, ThemeMode.SYSTEM>): string => {
const colorScheme = appTheme.colorSchemes?.[mode];
if (typeof colorScheme !== 'object') {
return MATERIAL_YOU_STABLE_SEED;
}
const primary = colorScheme.palette?.primary;
return typeof primary === 'object' && primary !== null && 'main' in primary && typeof primary.main === 'string'
? primary.main
: MATERIAL_YOU_STABLE_SEED;
};
export const createAppColorTheme = (
appTheme: AppTheme['muiTheme'],
dynamicColor: TAppThemeContext['dynamicColor'],
setPureBlackMode: boolean,
mode: Exclude<ThemeMode, ThemeMode.SYSTEM>,
): AppTheme['muiTheme'] => {
const appThemeWithDominantPrimaryColor = createAppThemeWithDynamicColors(
dynamicColor?.average.hex,
dynamicColor?.average.hex,
appTheme,
const lightSeed = getVibrantColorForTheme(dynamicColor, ThemeMode.LIGHT) ?? getThemeSeed(appTheme, ThemeMode.LIGHT);
const darkSeed = getVibrantColorForTheme(dynamicColor, ThemeMode.DARK) ?? getThemeSeed(appTheme, ThemeMode.DARK);
const lightOptions = createMaterialYouThemeOptions(createMaterialYouScheme(lightSeed, ThemeMode.LIGHT));
const darkOptions = createMaterialYouThemeOptions(
createMaterialYouScheme(darkSeed, ThemeMode.DARK, setPureBlackMode),
);
const themePrimaryColorForBackground = createMuiTheme({
...appThemeWithDominantPrimaryColor,
defaultColorScheme: mode,
});
const activeOptions = mode === ThemeMode.DARK ? darkOptions : lightOptions;
const themeBackgroundColor = deepmerge(appThemeWithDominantPrimaryColor, {
return deepmerge(appTheme, {
defaultColorScheme: mode,
shape: activeOptions.shape,
typography: deepmerge(activeOptions.typography, appTheme.typography ?? {}),
components: deepmerge(activeOptions.components, appTheme.components ?? {}),
colorSchemes: {
light: themePrimaryColorForBackground.colorSchemes?.light
? {
palette: {
background: getBackgroundColor(
'light',
appThemeWithDominantPrimaryColor,
themePrimaryColorForBackground,
),
},
}
: undefined,
dark: themePrimaryColorForBackground.colorSchemes?.dark
? {
palette: {
background: getBackgroundColor(
'dark',
appThemeWithDominantPrimaryColor,
themePrimaryColorForBackground,
setPureBlackMode,
),
},
}
: undefined,
light: { palette: lightOptions.palette },
dark: { palette: darkOptions.palette },
},
});
const appThemeWithVibrantPrimaryColor = createAppThemeWithDynamicColors(
getVibrantColorForTheme(dynamicColor, ThemeMode.DARK),
getVibrantColorForTheme(dynamicColor, ThemeMode.LIGHT),
themeBackgroundColor,
);
return deepmerge(themeBackgroundColor, appThemeWithVibrantPrimaryColor);
}) as AppTheme['muiTheme'];
};
export const createTheme = (
@@ -195,26 +99,26 @@ export const createTheme = (
direction,
typography: {
fontSize: 13,
...appTheme.muiTheme.typography,
...appColorTheme.typography,
},
components: {
...appTheme.muiTheme.components,
...appColorTheme.components,
MuiUseMediaQuery: {
defaultProps: {
noSsr: true,
},
},
MuiCssBaseline: {
...appTheme.muiTheme.components?.MuiCssBaseline,
...appColorTheme.components?.MuiCssBaseline,
styleOverrides:
typeof appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides === 'object'
typeof appColorTheme.components?.MuiCssBaseline?.styleOverrides === 'object'
? {
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides,
...appColorTheme.components?.MuiCssBaseline?.styleOverrides,
'*::-webkit-scrollbar': applyStyles(doesDeviceSupportHover, {
width: `${SCROLLBAR_SIZE}px`,
height: `${SCROLLBAR_SIZE}px`,
// @ts-ignore - '*::-webkit-scrollbar' is a valid key
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[
...appColorTheme.components?.MuiCssBaseline?.styleOverrides?.[
'*::-webkit-scrollbar'
],
}),
@@ -224,14 +128,14 @@ export const createTheme = (
borderRadius: '9999px',
backgroundColor: `${themeForColors.palette.primary[isDarkMode ? 'dark' : 'light']}`,
// @ts-ignore - '*::-webkit-scrollbar-thumb' is a valid key
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[
...appColorTheme.components?.MuiCssBaseline?.styleOverrides?.[
'*::-webkit-scrollbar-thumb'
],
}),
'*::-webkit-scrollbar-thumb:hover': applyStyles(doesDeviceSupportHover, {
borderWidth: '2px',
// @ts-ignore - '*::-webkit-scrollbar-thumb:hover' is a valid key
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[
...appColorTheme.components?.MuiCssBaseline?.styleOverrides?.[
'*::-webkit-scrollbar-thumb:hover'
],
}),
@@ -252,7 +156,7 @@ export const createTheme = (
border-width: 2px;
}
${appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides ?? ''}
${appColorTheme.components?.MuiCssBaseline?.styleOverrides ?? ''}
}
`,
},

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;
};

View File

@@ -83,6 +83,12 @@ const typePolicies: TypedTypePolicies = {
id: args?.id,
});
},
chapter(_, { args, toReference }) {
return toReference({
__typename: 'ChapterType',
id: args?.id,
});
},
category(_, { args, toReference }) {
return toReference({
__typename: 'CategoryType',
@@ -136,6 +142,12 @@ const typePolicies: TypedTypePolicies = {
lastSyncStatus(_, { toReference }) {
return toReference({ __typename: 'SyncStatus', key: {} });
},
tracker(_, { toReference }) {
return toReference({ __typename: 'TrackerType', key: {} });
},
trackRecord(_, { toReference }) {
return toReference({ __typename: 'TrackRecordType', key: {} });
},
mangas: {
keyArgs: ['condition', 'filter', 'orderBy', 'orderByType', 'order'],
merge(existing, incoming, { mergeObjects }) {

View File

@@ -0,0 +1,16 @@
/*
* 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 { execFileSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { formatWebUIRevision } from '@/features/theme/services/MaterialYouRevision.ts';
const commitCount = Number(execFileSync('git', ['rev-list', 'HEAD', '--count'], { encoding: 'utf8' }).trim());
const revisionPath = new URL('../../build/revision', import.meta.url);
writeFileSync(revisionPath, formatWebUIRevision(commitCount), 'utf8');

View File

@@ -3,6 +3,11 @@
"uiVersion": "PREVIEW",
"serverVersion": "r2243"
},
{
"tag": "v20260722.01",
"uiVersion": "r3355",
"serverVersion": "r2243"
},
{
"tag": "v20260509.01",
"uiVersion": "r3147",