Compare commits

...

10 Commits

36 changed files with 1365 additions and 310 deletions

View File

@@ -36,27 +36,6 @@ body:
validations: validations:
required: true 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 - type: input
id: server-os id: server-os
attributes: attributes:
@@ -87,33 +66,22 @@ body:
validations: validations:
required: true required: true
- type: input - type: textarea
id: suwayomi-webui-version id: debug-information
attributes: attributes:
label: Suwayomi-WebUI version label: Debug information
description: You can find your Suwayomi-WebUI version in **More → About**. description: |
placeholder: | Provide the debug information from the About page.
Example: "r1689 STABLE | r2401 PREVIEW" _Note:_ You can paste the copied debug information into the placeholder below.
validations: value: |
required: true <details>
<summary>Click here to see debug information</summary>
<!-- Please leave one blank line below for enabling the code block rendering. -->
- type: input ```
id: client-browser Replace this text with the copied debug information
attributes: ```
label: Used web browser </details>
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"
validations: validations:
required: true required: true

View File

@@ -8,6 +8,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added ### 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 outdated matches
- (**Migration**) Add a search option to ignore matches with missing chapters - (**Migration**) Add a search option to ignore matches with missing chapters
- (**Migration**) Add "local source" as a possible destination source - (**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/WebView**) Add setting to enable/disable WebView
- (**Settings/Sync**) Add sync settings - (**Settings/Sync**) Add sync settings
- (**Reader**) Add setting to keep the screen on while reading - (**Reader**) Add setting to keep the screen on while reading
- (**Extension**) Support installing external JARs
- (**Reader**) Add auto background color setting - (**Reader**) Add auto background color setting
- (**Extension**) Support installing external JARs
- (**Library**) Add source filter - (**Library**) Add source filter
- (**Library**) Add random sort option to library - (**Library**) Add random sort option to library
- (**Library**) Add button to open random library entry - (**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 dialog on manual search selection to optionally open the search result entry instead of selecting it
- (**Migration**) Show "manual search matches" under "other matches" - (**Migration**) Show "manual search matches" under "other matches"
- (**Source/Extension**) Rename language "All" to "Multi" - (**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**) Simplify changing settings in desktop sidebar
- (**Reader**) Ignore tap zone clicks while window does not have focus - (**Reader**) Ignore tap zone clicks while window does not have focus
- (**Reader**) Improve preloading pages from the previous/next chapter - (**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 not respecting chapter list filters for downloads
- (**Download**) Fix showing outdated download info in for some chapter cards after clearing the download queue - (**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 ## [20260509.01] (r3147) - 2026-05-09
### Fixed ### 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 @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 [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 [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 [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", "setup": "pnpm i --frozen-lockfile && pnpm setup-env-files",
"dev": "pnpm setup && vite", "dev": "pnpm setup && vite",
"preview": "pnpm setup && vite preview", "preview": "pnpm setup && vite preview",
"build": "pnpm setup && vite build", "build": "pnpm setup && vite build && pnpm build-revision",
"test": "node -e \"console.log('imagine')\"", "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-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 *", "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", "lint": "oxlint src tools",

View File

@@ -16,3 +16,5 @@ export const enhancedCleanup = (str: string): string =>
export const reverseString = (str: string, separator: string = ''): string => export const reverseString = (str: string, separator: string = ''): string =>
str.split(separator).reverse().join(separator); 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 { requestManager } from '@/lib/requests/RequestManager.ts';
import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx'; 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 { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx'; import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
@@ -127,9 +127,8 @@ const ExtensionStoreCard = ({
)} )}
<CustomTooltip title={t`Copy index url`}> <CustomTooltip title={t`Copy index url`}>
<IconButton <IconButton
onClick={async () => { onClick={() => {
await navigator.clipboard.writeText(indexUrl); copyToClipboard(indexUrl);
makeToast(t`Copied to clipboard`, 'info');
}} }}
color="inherit" 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 { MANGA_COVER_ASPECT_RATIO } from '@/features/manga/Manga.constants.ts';
import { GridLayout } from '@/base/Base.types.ts'; import { GridLayout } from '@/base/Base.types.ts';
import { MediaQuery } from '@/base/utils/MediaQuery.tsx'; import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
import { MATERIAL_YOU_VISUALS } from '@/features/theme/services/MaterialYouVisualTokens.ts';
const BottomGradient = styled('div')({ const BottomGradient = styled('div')({
position: 'absolute', position: 'absolute',
@@ -78,10 +79,15 @@ export const MangaGridCard = memo(
flexDirection: 'column', flexDirection: 'column',
m: 0.25, m: 0.25,
outline: selected ? '4px solid' : undefined, outline: selected ? '4px solid' : undefined,
borderRadius: selected ? '1px' : undefined, borderRadius: MATERIAL_YOU_VISUALS.cardRadiusCss,
outlineColor: (theme) => theme.palette.primary.main, outlineColor: (theme) => theme.palette.primary.main,
backgroundColor: (theme) => (selected ? theme.palette.primary.main : undefined), 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)': { '@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': { '&:hover .manga-option-button': {
visibility: 'visible', visibility: 'visible',
pointerEvents: 'all', pointerEvents: 'all',
@@ -100,6 +106,7 @@ export const MangaGridCard = memo(
// force standard aspect ratio of manga covers // force standard aspect ratio of manga covers
aspectRatio: MANGA_COVER_ASPECT_RATIO, aspectRatio: MANGA_COVER_ASPECT_RATIO,
display: 'flex', display: 'flex',
borderRadius: MATERIAL_YOU_VISUALS.cardRadiusCss,
}} }}
> >
<CardActionArea <CardActionArea

View File

@@ -27,7 +27,6 @@ import { FlexWrapButton } from '@/base/components/buttons/FlexWrapButton.tsx';
import { TrackMangaButton } from '@/features/manga/components/TrackMangaButton.tsx'; import { TrackMangaButton } from '@/features/manga/components/TrackMangaButton.tsx';
import { useManageMangaLibraryState } from '@/features/manga/hooks/useManageMangaLibraryState.tsx'; import { useManageMangaLibraryState } from '@/features/manga/hooks/useManageMangaLibraryState.tsx';
import { Metadata as BaseMetadata } from '@/base/components/texts/Metadata.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 type { MangaType, SourceType } from '@/lib/graphql/generated/graphql-base.types.ts';
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts'; import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import { MANGA_STATUS_TO_TRANSLATION } from '@/features/manga/Manga.constants.ts'; import { MANGA_STATUS_TO_TRANSLATION } from '@/features/manga/Manga.constants.ts';
@@ -45,7 +44,6 @@ import type {
MangaTitleInfo, MangaTitleInfo,
MangaTrackRecordInfo, MangaTrackRecordInfo,
} from '@/features/manga/Manga.types.ts'; } from '@/features/manga/Manga.types.ts';
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
import { CustomIconButton } from '@/base/components/buttons/CustomIconButton.tsx'; import { CustomIconButton } from '@/base/components/buttons/CustomIconButton.tsx';
import { Sources } from '@/features/source/services/Sources.ts'; import { Sources } from '@/features/source/services/Sources.ts';
import type { SourceIdInfo } from '@/features/source/Source.types.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 { IconBrowser } from '@/assets/icons/IconBrowser.tsx';
import { IconWebView } from '@/assets/icons/IconWebView.tsx'; import { IconWebView } from '@/assets/icons/IconWebView.tsx';
import { MediaQuery } from '@/base/utils/MediaQuery.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 }) => ({ const DetailsWrapper = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: theme.spacing(2), gap: theme.spacing(3),
padding: theme.spacing(1), padding: theme.spacing(2),
[theme.breakpoints.up('md')]: { [theme.breakpoints.up('md')]: {
flexBasis: '40%', flexBasis: '40%',
height: 'calc(100vh - 64px)', height: 'calc(100vh - 64px)',
@@ -81,6 +81,10 @@ const TopContentWrapper = ({
<Stack <Stack
sx={{ sx={{
position: 'relative', position: 'relative',
overflow: 'hidden',
borderRadius: `${MATERIAL_YOU_VISUALS.detailRadius}px`,
backgroundColor: 'background.paper',
p: { xs: 1.5, sm: 2 },
}} }}
> >
{mangaThumbnailBackdrop && ( {mangaThumbnailBackdrop && (
@@ -103,23 +107,22 @@ const TopContentWrapper = ({
width: '100%', width: '100%',
height: '100%', height: '100%',
objectFit: 'cover', objectFit: 'cover',
opacity: 0.18,
filter: 'blur(24px) saturate(1.2)',
transform: 'scale(1.12)',
}} }}
src={url} src={url}
alt="Manga Thumbnail" alt="Manga Thumbnail"
/> />
<Stack <Stack
sx={{ sx={{
'&::before': (theme) => '&::before': (theme) => ({
applyStyles(mangaThumbnailBackdrop, {
position: 'absolute', position: 'absolute',
display: 'inline-block', display: 'inline-block',
content: '""', content: '""',
top: 0, inset: 0,
left: 0, backgroundColor: theme.alpha(theme.palette.background.paper, 0.7),
width: '100%', backdropFilter: 'blur(10px)',
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)',
}), }),
}} }}
/> />
@@ -131,19 +134,21 @@ const TopContentWrapper = ({
const ThumbnailMetadataWrapper = styled('div')(({ theme }) => ({ const ThumbnailMetadataWrapper = styled('div')(({ theme }) => ({
display: 'flex', 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, zIndex: 1,
marginLeft: theme.spacing(1), minWidth: 0,
})); });
const Metadata = (props: ComponentProps<typeof BaseMetadata>) => <BaseMetadata {...props} />; const Metadata = (props: ComponentProps<typeof BaseMetadata>) => <BaseMetadata {...props} />;
const MangaButtonsContainer = styled('div')(({ theme }) => ({ const MangaButtonsContainer = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
gap: theme.spacing(1), gap: theme.spacing(1),
flexWrap: 'wrap',
})); }));
const OpenSourceButton = ({ url }: { url?: string | null }) => { const OpenSourceButton = ({ url }: { url?: string | null }) => {
@@ -240,15 +245,6 @@ export const MangaDetails = ({
const { updateLibraryState } = useManageMangaLibraryState(manga); 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 ( return (
<DetailsWrapper> <DetailsWrapper>
<TopContentWrapper url={Mangas.getThumbnailUrl(manga)} mangaThumbnailBackdrop={mangaThumbnailBackdrop}> <TopContentWrapper url={Mangas.getThumbnailUrl(manga)} mangaThumbnailBackdrop={mangaThumbnailBackdrop}>
@@ -263,7 +259,7 @@ export const MangaDetails = ({
</SearchLink> </SearchLink>
{!!navigator.clipboard && ( {!!navigator.clipboard && (
<CustomTooltip title={t`Copy`}> <CustomTooltip title={t`Copy`}>
<IconButton onClick={copyTitle} color="inherit"> <IconButton onClick={() => copyToClipboard(manga.title)} color="inherit">
<ContentCopyIcon fontSize="small" /> <ContentCopyIcon fontSize="small" />
</IconButton> </IconButton>
</CustomTooltip> </CustomTooltip>

View File

@@ -7,7 +7,7 @@
*/ */
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import { useLayoutEffect, useState } from 'react'; import { useLayoutEffect, useRef, useState } from 'react';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import OpenInFullIcon from '@mui/icons-material/OpenInFull'; import OpenInFullIcon from '@mui/icons-material/OpenInFull';
import Modal from '@mui/material/Modal'; 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 type { ImageRequest } from '@/lib/requests/RequestManager.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { noOp } from '@/lib/HelperFunctions.ts'; import { noOp } from '@/lib/HelperFunctions.ts';
import {
isDynamicColorResultCurrent,
MATERIAL_YOU_VISUALS,
} from '@/features/theme/services/MaterialYouVisualTokens.ts';
export const Thumbnail = ({ export const Thumbnail = ({
manga, manga,
@@ -39,6 +43,8 @@ export const Thumbnail = ({
const [isImageReady, setIsImageReady] = useState(false); const [isImageReady, setIsImageReady] = useState(false);
const url = Mangas.getThumbnailUrl(manga); const url = Mangas.getThumbnailUrl(manga);
const latestUrlRef = useRef(url);
latestUrlRef.current = url;
useLayoutEffect(() => { useLayoutEffect(() => {
if (!mangaDynamicColorSchemes) { if (!mangaDynamicColorSchemes) {
@@ -56,7 +62,7 @@ export const Thumbnail = ({
imageRequest = await requestManager.requestImage(url); imageRequest = await requestManager.requestImage(url);
const image = await imageRequest.response; const image = await imageRequest.response;
if (aborted) { if (!isDynamicColorResultCurrent(url, latestUrlRef.current, aborted)) {
return; return;
} }
@@ -65,7 +71,7 @@ export const Thumbnail = ({
img.src = image; img.src = image;
img.onload = () => { img.onload = () => {
if (aborted) { if (!isDynamicColorResultCurrent(url, latestUrlRef.current, aborted)) {
return; return;
} }
@@ -81,8 +87,9 @@ export const Thumbnail = ({
[0, 0, 0, 255, 75], [0, 0, 0, 255, 75],
], ],
}), }),
]).then(([palette, averageColor]) => { ])
if (aborted) { .then(([palette, averageColor]) => {
if (!isDynamicColorResultCurrent(url, latestUrlRef.current, aborted)) {
return; return;
} }
@@ -101,7 +108,8 @@ export const Thumbnail = ({
...palette, ...palette,
average: averageColor, average: averageColor,
} as TAppThemeContext['dynamicColor']); } as TAppThemeContext['dynamicColor']);
}); })
.catch(noOp);
}; };
}; };
@@ -120,7 +128,7 @@ export const Thumbnail = ({
<Stack <Stack
sx={{ sx={{
position: 'relative', position: 'relative',
borderRadius: 1, borderRadius: `${MATERIAL_YOU_VISUALS.cardRadius}px`,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: 'background.paper', backgroundColor: 'background.paper',
width: '125px', 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 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. * Returns the key with the provided prefixes.
* *
@@ -34,7 +36,7 @@ export const getMetadataKey = (key: string, prefixes: string[] = [], appPrefix:
(prefix) => prefix.toLowerCase() !== 'default', (prefix) => prefix.toLowerCase() !== 'default',
); );
return `${finalPrefix.join('_')}_${key}`; return getMetadataKeyRaw(key, finalPrefix);
}; };
export const doesAppMetadataKeyExistIn = ( export const doesAppMetadataKeyExistIn = (
@@ -46,3 +48,19 @@ export const doesAppMetadataKeyExistIn = (
export const doesMetadataKeyExistIn = (meta: Metadata | undefined, key: string): boolean => export const doesMetadataKeyExistIn = (meta: Metadata | undefined, key: string): boolean =>
Object.prototype.hasOwnProperty.call(meta ?? {}, key); 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, MetadataHolderType,
MetadataKeyValuePair, MetadataKeyValuePair,
} from '@/features/metadata/Metadata.types.ts'; } 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 { MangaIdInfo } from '@/features/manga/Manga.types.ts';
import type { CategoryIdInfo } from '@/features/category/Category.types.ts'; import type { CategoryIdInfo } from '@/features/category/Category.types.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
@@ -37,22 +37,6 @@ const getAppKeyPrefixForMigration = (migrationId: number): string => {
return appKeyPrefix?.appKeyPrefix?.newPrefix ?? APP_METADATA_KEY_PREFIX; 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 applyAppKeyPrefixMigration = (meta: Metadata, migration: IMetadataMigration): Metadata => {
const migratedMetadata: Metadata = { ...meta }; 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)', pt: 'env(safe-area-inset-top)',
width: `calc(100% - ${actualNavBarWidth}px)`, width: `calc(100% - ${actualNavBarWidth}px)`,
zIndex: theme.zIndex.drawer, zIndex: theme.zIndex.drawer,
borderBottom: '1px solid',
borderColor: 'divider',
}} }}
> >
<Toolbar sx={{ position: 'relative' }}> <Toolbar sx={{ position: 'relative', minHeight: { xs: 64, sm: 72 } }}>
{!isMobileWidth && ( {!isMobileWidth && (
<Stack <Stack
sx={{ sx={{
@@ -130,7 +132,11 @@ export function DefaultNavBar() {
alignItems: 'center', alignItems: 'center',
}} }}
> >
<IconButton aria-label="open drawer" onClick={() => setIsCollapsed(false)} color="inherit"> <IconButton
aria-label="Expand navigation"
onClick={() => setIsCollapsed(false)}
color="inherit"
>
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
</Stack> </Stack>
@@ -156,12 +162,13 @@ export function DefaultNavBar() {
</IconButton> </IconButton>
)} )}
<Typography <Typography
variant="h5" variant="h6"
component="h1" component="h1"
noWrap noWrap
sx={{ sx={{
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
flexGrow: 1, flexGrow: 1,
letterSpacing: '-0.01em',
}} }}
> >
{title} {title}

View File

@@ -11,7 +11,6 @@ import List from '@mui/material/List';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import Divider from '@mui/material/Divider';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import Box from '@mui/material/Box'; 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 { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
import type { NavbarItem } from '@/features/navigation-bar/NavigationBar.types.ts'; import type { NavbarItem } from '@/features/navigation-bar/NavigationBar.types.ts';
import { NavigationBarItem } from '@/features/navigation-bar/components/NavigationBarItem.tsx'; import { NavigationBarItem } from '@/features/navigation-bar/components/NavigationBarItem.tsx';
import { MATERIAL_YOU_NAVIGATION } from '@/features/navigation-bar/MaterialYouNavigation.ts';
const DrawerHeader = styled('div')(({ theme }) => ({ const DrawerHeader = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'flex-end', justifyContent: 'flex-end',
padding: theme.spacing(0, 1), minHeight: 72,
padding: theme.spacing(1),
// necessary for content to be below app bar // necessary for content to be below app bar
...theme.mixins.toolbar, ...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[] }) => { export const DesktopSideBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) => {
const { isCollapsed, setIsCollapsed, navBarWidth, setNavBarWidth } = useNavBarContext(); const { isCollapsed, setIsCollapsed, navBarWidth, setNavBarWidth } = useNavBarContext();
const getOptionForDirection = useGetOptionForDirection(); const getOptionForDirection = useGetOptionForDirection();
@@ -52,11 +48,15 @@ export const DesktopSideBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) =
width: navBarWidth, width: navBarWidth,
'& .MuiDrawer-paper': { '& .MuiDrawer-paper': {
zIndex: (theme) => theme.zIndex.drawer - 1, zIndex: (theme) => theme.zIndex.drawer - 1,
overflowX: 'hidden',
transition: (theme) => theme.transitions.create('width'),
}, },
}} }}
slotProps={{ slotProps={{
paper: { paper: {
ref, ref,
component: 'nav',
'aria-label': MATERIAL_YOU_NAVIGATION.landmarkLabel,
}, },
}} }}
> >
@@ -64,17 +64,17 @@ export const DesktopSideBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) =
sx={{ sx={{
pt: 'env(safe-area-inset-top)', pt: 'env(safe-area-inset-top)',
pl: 'env(safe-area-inset-left)', pl: 'env(safe-area-inset-left)',
minWidth: isCollapsed ? MIN_WIDTH_COLLAPSED : MIN_WIDTH_EXTENDED, width: isCollapsed ? MATERIAL_YOU_NAVIGATION.railWidth : MATERIAL_YOU_NAVIGATION.expandedWidth,
maxWidth: isCollapsed ? MAX_WIDTH_COLLAPSED : MAX_WIDTH_EXTENDED,
}} }}
> >
<DrawerHeader> <DrawerHeader sx={{ justifyContent: isCollapsed ? 'center' : 'flex-end' }}>
<IconButton onClick={() => setIsCollapsed(true)}> {!isCollapsed && (
<IconButton aria-label="Collapse navigation" onClick={() => setIsCollapsed(true)}>
{getOptionForDirection(<ChevronLeftIcon />, <ChevronRightIcon />)} {getOptionForDirection(<ChevronLeftIcon />, <ChevronRightIcon />)}
</IconButton> </IconButton>
)}
</DrawerHeader> </DrawerHeader>
<Divider /> <List sx={{ px: 1.5, py: 1, display: 'grid', gap: 0.5 }} dense={isCollapsed}>
<List sx={{ p: 1 }} dense={isCollapsed}>
{navBarItems.map((navBarItem) => ( {navBarItems.map((navBarItem) => (
<NavigationBarItem key={navBarItem.path} {...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 type { NavbarItem } from '@/features/navigation-bar/NavigationBar.types.ts';
import { NavigationBarItem } from '@/features/navigation-bar/components/NavigationBarItem.tsx'; import { NavigationBarItem } from '@/features/navigation-bar/components/NavigationBarItem.tsx';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import { MATERIAL_YOU_NAVIGATION } from '@/features/navigation-bar/MaterialYouNavigation.ts';
export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) => { export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) => {
const theme = useTheme(); const theme = useTheme();
@@ -30,6 +31,8 @@ export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] })
return ( return (
<Paper <Paper
component="nav"
aria-label={MATERIAL_YOU_NAVIGATION.landmarkLabel}
ref={ref} ref={ref}
sx={{ sx={{
position: 'fixed', position: 'fixed',
@@ -40,15 +43,18 @@ export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] })
pl: 'env(safe-area-inset-left)', pl: 'env(safe-area-inset-left)',
pr: 'env(safe-area-inset-right)', pr: 'env(safe-area-inset-right)',
zIndex: theme.zIndex.drawer - 1, zIndex: theme.zIndex.drawer - 1,
minHeight: MATERIAL_YOU_NAVIGATION.bottomBarMinHeight,
borderRadius: '28px 28px 0 0',
backgroundColor: 'background.paper',
}} }}
style={{ style={{
...(theme.applyStyles('dark', { ...(theme.applyStyles('dark', {
'--Paper-overlay': 'unset', '--Paper-overlay': 'unset',
}) as CSSProperties), }) 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) => ( {navBarItems.map((item) => (
<NavigationBarItem <NavigationBarItem
key={item.path} key={item.path}
@@ -56,7 +62,7 @@ export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] })
slots={{ slots={{
listItemLink: { listItemLink: {
sx: { 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 Typography from '@mui/material/Typography';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx'; import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
import { MATERIAL_YOU_NAVIGATION } from '@/features/navigation-bar/MaterialYouNavigation.ts';
export const NavigationBarItem = ({ export const NavigationBarItem = ({
path, path,
@@ -49,8 +50,20 @@ export const NavigationBarItem = ({
const { listItemProps, listItemIconProps } = useMemo( const { listItemProps, listItemIconProps } = useMemo(
() => ({ () => ({
listItemProps: isCollapsed ? { p: 0.5, display: 'flex', flexDirection: 'column' } : {}, listItemProps: isCollapsed
listItemIconProps: isCollapsed ? { justifyContent: 'center' } : {}, ? { 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], [isCollapsed],
); );
@@ -59,7 +72,15 @@ export const NavigationBarItem = ({
<ListItemLink <ListItemLink
{...slots?.listItemLink} {...slots?.listItemLink}
selected={!isCollapsed && isActive} 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} to={path}
> >
<CustomTooltip <CustomTooltip
@@ -77,10 +98,13 @@ export const NavigationBarItem = ({
<Badge badgeContent={badgeInfo?.count} color="primary"> <Badge badgeContent={badgeInfo?.count} color="primary">
<Icon <Icon
sx={{ sx={{
color: isCollapsed && isActive ? 'primary.contrastText' : undefined,
...(!isCollapsed && {
color: isActive ? 'primary.dark' : undefined, color: isActive ? 'primary.dark' : undefined,
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
color: isActive ? 'primary.light' : undefined, color: isActive ? 'primary.light' : undefined,
}), }),
}),
}} }}
/> />
</Badge> </Badge>
@@ -92,10 +116,14 @@ export const NavigationBarItem = ({
lines={1} lines={1}
variant={isCollapsed ? 'caption' : undefined} variant={isCollapsed ? 'caption' : undefined}
sx={{ sx={{
color: isCollapsed && isActive ? 'text.primary' : undefined,
fontWeight: isActive ? 700 : 500,
...(!isCollapsed && {
color: isActive ? 'primary.dark' : undefined, color: isActive ? 'primary.dark' : undefined,
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
color: isActive ? 'primary.light' : undefined, color: isActive ? 'primary.light' : undefined,
}), }),
}),
}} }}
> >
{t(title)} {t(title)}

View File

@@ -20,7 +20,6 @@ import { memo } from 'react';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx'; import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import { TypographyMaxLines } from '@/base/components/texts/TypographyMaxLines.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 type { MobileHeaderProps } from '@/features/reader/overlay/ReaderOverlay.types.ts';
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx'; import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
import { AppRoutes } from '@/base/AppRoute.constants.ts'; import { AppRoutes } from '@/base/AppRoute.constants.ts';
@@ -35,6 +34,8 @@ import {
useReaderScrollbarStore, useReaderScrollbarStore,
useReaderStore, useReaderStore,
} from '@/features/reader/stores/ReaderStore.ts'; } 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: '' }; const DEFAULT_MANGA = { ...FALLBACK_MANGA, title: '' };
@@ -61,9 +62,12 @@ const BaseReaderOverlayHeaderMobile = ({ isVisible, ref }: MobileHeaderProps & {
right: `${scrollbar.ySize}px`, right: `${scrollbar.ySize}px`,
p: 2, p: 2,
pt: (theme) => `max(env(safe-area-inset-top), ${theme.spacing(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', pointerEvents: 'all',
boxShadow: 2, borderBottom: '1px solid',
borderColor: 'divider',
}} }}
> >
<ReaderExitButton /> <ReaderExitButton />
@@ -113,13 +117,7 @@ const BaseReaderOverlayHeaderMobile = ({ isVisible, ref }: MobileHeaderProps & {
> >
{t`Open in WebView`} {t`Open in WebView`}
</MenuItem> </MenuItem>
<MenuItem <MenuItem disabled={!realUrl} onClick={() => copyToClipboard(title)}>
disabled={!realUrl}
onClick={async () => {
await navigator.clipboard.writeText(title);
makeToast(t`Copied to clipboard`, 'info');
}}
>
{t`Share`} {t`Share`}
</MenuItem> </MenuItem>
</Menu> </Menu>

View File

@@ -34,6 +34,7 @@ import {
useReaderSettingsStore, useReaderSettingsStore,
useReaderStore, useReaderStore,
} from '@/features/reader/stores/ReaderStore.ts'; } from '@/features/reader/stores/ReaderStore.ts';
import { MATERIAL_YOU_VISUALS } from '@/features/theme/services/MaterialYouVisualTokens.ts';
const useGetPreviousNavBarStaticValue = (isVisible: boolean, isStaticNav: boolean) => { const useGetPreviousNavBarStaticValue = (isVisible: boolean, isStaticNav: boolean) => {
const wasNavBarStaticRef = useRef(isStaticNav); const wasNavBarStaticRef = useRef(isStaticNav);
@@ -104,6 +105,12 @@ const BaseReaderNavBarDesktop = ({
slotProps={{ slotProps={{
paper: { paper: {
ref: (ref: HTMLDivElement | null) => setNavBarElement(ref), ref: (ref: HTMLDivElement | null) => setNavBarElement(ref),
sx: {
m: 1,
height: 'calc(100% - 16px)',
borderRadius: `${MATERIAL_YOU_VISUALS.detailRadius}px`,
overflow: 'hidden',
},
}, },
transition: { transition: {
unmountOnExit: true, unmountOnExit: true,
@@ -111,7 +118,7 @@ const BaseReaderNavBarDesktop = ({
}} }}
> >
<ReaderNavContainer sx={{ backgroundColor: 'background.paper', pointerEvents: 'all' }}> <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' }}> <Stack sx={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<ReaderExitButton /> <ReaderExitButton />
<CustomTooltip title={t`Static navigation`}> <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 { ReaderBottomBarMobileQuickSettings } from '@/features/reader/overlay/navigation/mobile/quick-settings/ReaderBottomBarMobileQuickSettings.tsx';
import { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx'; import { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
import { useReaderChaptersStore, useReaderScrollbarStore } from '@/features/reader/stores/ReaderStore.ts'; import { useReaderChaptersStore, useReaderScrollbarStore } from '@/features/reader/stores/ReaderStore.ts';
import { MATERIAL_YOU_VISUALS } from '@/features/theme/services/MaterialYouVisualTokens.ts';
const BaseReaderBottomBarMobile = ({ const BaseReaderBottomBarMobile = ({
openSettings, openSettings,
@@ -68,9 +69,13 @@ const BaseReaderBottomBarMobile = ({
ref={bottomBarRef} ref={bottomBarRef}
sx={{ sx={{
alignItems: 'center', 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))`, pb: `max(${scrollbar.xSize}px, env(safe-area-inset-bottom))`,
boxShadow: 2, borderTop: '1px solid',
borderColor: 'divider',
borderRadius: '28px 28px 0 0',
pointerEvents: 'all', 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 { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { epochToDate } from '@/base/utils/DateHelper.ts'; import { epochToDate } from '@/base/utils/DateHelper.ts';
import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.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() { export function About() {
const { t } = useLingui(); const { t } = useLingui();
@@ -160,6 +162,17 @@ export function About() {
<ListItemText primary={t`Discord`} secondary={aboutServer.discord} /> <ListItemText primary={t`Discord`} secondary={aboutServer.discord} />
</ListItemLink> </ListItemLink>
</List> </List>
<List
subheader={
<ListSubheader component="div" id="about-webui-info">
{t`Debug information`}
</ListSubheader>
}
>
<Stack sx={{ px: 2, py: 1 }}>
<DebugInformation />
</Stack>
</List>
</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/. * 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 { createTheme as createMuiTheme, responsiveFontSizes, useTheme } from '@mui/material/styles';
import { useCallback } from 'react'; import { useCallback } from 'react';
// oxlint-disable-next-line no-restricted-imports // oxlint-disable-next-line no-restricted-imports
import { deepmerge } from '@mui/utils'; 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 type { AppTheme } from '@/features/theme/services/AppThemes.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { applyStyles } from '@/base/utils/ApplyStyles.ts'; import { applyStyles } from '@/base/utils/ApplyStyles.ts';
import type { TAppThemeContext } from '@/features/theme/AppTheme.types.ts'; import type { TAppThemeContext } from '@/features/theme/AppTheme.types.ts';
import { ThemeMode } from '@/features/theme/AppTheme.types.ts'; import { ThemeMode } from '@/features/theme/AppTheme.types.ts';
import { ThemeFontLoader } from '@/features/theme/services/ThemeFontLoader.ts'; import { ThemeFontLoader } from '@/features/theme/services/ThemeFontLoader.ts';
import { coerceIn } from '@/lib/HelperFunctions.ts';
import { MediaQuery } from '@/base/utils/MediaQuery.tsx'; 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 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 = ( const getVibrantColorForTheme = (
palette: TAppThemeContext['dynamicColor'] | null, palette: TAppThemeContext['dynamicColor'] | null,
mode: Exclude<ThemeMode, ThemeMode.SYSTEM>, mode: Exclude<ThemeMode, ThemeMode.SYSTEM>,
@@ -115,57 +34,42 @@ const getVibrantColorForTheme = (
return mode === ThemeMode.LIGHT ? palette.DarkVibrant.hex : palette.LightVibrant.hex; 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 = ( export const createAppColorTheme = (
appTheme: AppTheme['muiTheme'], appTheme: AppTheme['muiTheme'],
dynamicColor: TAppThemeContext['dynamicColor'], dynamicColor: TAppThemeContext['dynamicColor'],
setPureBlackMode: boolean, setPureBlackMode: boolean,
mode: Exclude<ThemeMode, ThemeMode.SYSTEM>, mode: Exclude<ThemeMode, ThemeMode.SYSTEM>,
): AppTheme['muiTheme'] => { ): AppTheme['muiTheme'] => {
const appThemeWithDominantPrimaryColor = createAppThemeWithDynamicColors( const lightSeed = getVibrantColorForTheme(dynamicColor, ThemeMode.LIGHT) ?? getThemeSeed(appTheme, ThemeMode.LIGHT);
dynamicColor?.average.hex, const darkSeed = getVibrantColorForTheme(dynamicColor, ThemeMode.DARK) ?? getThemeSeed(appTheme, ThemeMode.DARK);
dynamicColor?.average.hex, const lightOptions = createMaterialYouThemeOptions(createMaterialYouScheme(lightSeed, ThemeMode.LIGHT));
appTheme, const darkOptions = createMaterialYouThemeOptions(
createMaterialYouScheme(darkSeed, ThemeMode.DARK, setPureBlackMode),
); );
const themePrimaryColorForBackground = createMuiTheme({ const activeOptions = mode === ThemeMode.DARK ? darkOptions : lightOptions;
...appThemeWithDominantPrimaryColor,
defaultColorScheme: mode,
});
const themeBackgroundColor = deepmerge(appThemeWithDominantPrimaryColor, { return deepmerge(appTheme, {
defaultColorScheme: mode, defaultColorScheme: mode,
shape: activeOptions.shape,
typography: deepmerge(activeOptions.typography, appTheme.typography ?? {}),
components: deepmerge(activeOptions.components, appTheme.components ?? {}),
colorSchemes: { colorSchemes: {
light: themePrimaryColorForBackground.colorSchemes?.light light: { palette: lightOptions.palette },
? { dark: { palette: darkOptions.palette },
palette: {
background: getBackgroundColor(
'light',
appThemeWithDominantPrimaryColor,
themePrimaryColorForBackground,
),
}, },
} }) as AppTheme['muiTheme'];
: undefined,
dark: themePrimaryColorForBackground.colorSchemes?.dark
? {
palette: {
background: getBackgroundColor(
'dark',
appThemeWithDominantPrimaryColor,
themePrimaryColorForBackground,
setPureBlackMode,
),
},
}
: undefined,
},
});
const appThemeWithVibrantPrimaryColor = createAppThemeWithDynamicColors(
getVibrantColorForTheme(dynamicColor, ThemeMode.DARK),
getVibrantColorForTheme(dynamicColor, ThemeMode.LIGHT),
themeBackgroundColor,
);
return deepmerge(themeBackgroundColor, appThemeWithVibrantPrimaryColor);
}; };
export const createTheme = ( export const createTheme = (
@@ -195,26 +99,26 @@ export const createTheme = (
direction, direction,
typography: { typography: {
fontSize: 13, fontSize: 13,
...appTheme.muiTheme.typography, ...appColorTheme.typography,
}, },
components: { components: {
...appTheme.muiTheme.components, ...appColorTheme.components,
MuiUseMediaQuery: { MuiUseMediaQuery: {
defaultProps: { defaultProps: {
noSsr: true, noSsr: true,
}, },
}, },
MuiCssBaseline: { MuiCssBaseline: {
...appTheme.muiTheme.components?.MuiCssBaseline, ...appColorTheme.components?.MuiCssBaseline,
styleOverrides: 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, { '*::-webkit-scrollbar': applyStyles(doesDeviceSupportHover, {
width: `${SCROLLBAR_SIZE}px`, width: `${SCROLLBAR_SIZE}px`,
height: `${SCROLLBAR_SIZE}px`, height: `${SCROLLBAR_SIZE}px`,
// @ts-ignore - '*::-webkit-scrollbar' is a valid key // @ts-ignore - '*::-webkit-scrollbar' is a valid key
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[ ...appColorTheme.components?.MuiCssBaseline?.styleOverrides?.[
'*::-webkit-scrollbar' '*::-webkit-scrollbar'
], ],
}), }),
@@ -224,14 +128,14 @@ export const createTheme = (
borderRadius: '9999px', borderRadius: '9999px',
backgroundColor: `${themeForColors.palette.primary[isDarkMode ? 'dark' : 'light']}`, backgroundColor: `${themeForColors.palette.primary[isDarkMode ? 'dark' : 'light']}`,
// @ts-ignore - '*::-webkit-scrollbar-thumb' is a valid key // @ts-ignore - '*::-webkit-scrollbar-thumb' is a valid key
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[ ...appColorTheme.components?.MuiCssBaseline?.styleOverrides?.[
'*::-webkit-scrollbar-thumb' '*::-webkit-scrollbar-thumb'
], ],
}), }),
'*::-webkit-scrollbar-thumb:hover': applyStyles(doesDeviceSupportHover, { '*::-webkit-scrollbar-thumb:hover': applyStyles(doesDeviceSupportHover, {
borderWidth: '2px', borderWidth: '2px',
// @ts-ignore - '*::-webkit-scrollbar-thumb:hover' is a valid key // @ts-ignore - '*::-webkit-scrollbar-thumb:hover' is a valid key
...appTheme.muiTheme.components?.MuiCssBaseline?.styleOverrides?.[ ...appColorTheme.components?.MuiCssBaseline?.styleOverrides?.[
'*::-webkit-scrollbar-thumb:hover' '*::-webkit-scrollbar-thumb:hover'
], ],
}), }),
@@ -252,7 +156,7 @@ export const createTheme = (
border-width: 2px; 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" msgid "Convert images to different formats"
msgstr "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/manga/components/details/MangaDetails.tsx
#: src/features/migration/components/MigrationOptionsDialog.tsx #: src/features/migration/components/MigrationOptionsDialog.tsx
#: src/features/settings/components/DebugInformation.tsx
msgid "Copy" msgid "Copy"
msgstr "Copy" msgstr "Copy"
@@ -1072,6 +1067,10 @@ msgstr "Could not install the extension"
msgid "Could not load categories" msgid "Could not load categories"
msgstr "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 #: src/features/settings/screens/Appearance.tsx
msgid "Could not load language" msgid "Could not load language"
msgstr "Could not load language" msgstr "Could not load language"
@@ -1314,6 +1313,10 @@ msgstr "Database url"
msgid "Day" msgid "Day"
msgstr "Day" msgstr "Day"
#: src/features/settings/screens/About.tsx
msgid "Debug information"
msgstr "Debug information"
#: src/features/reader/hotkeys/settings/components/ReaderSettingHotkey.tsx #: src/features/reader/hotkeys/settings/components/ReaderSettingHotkey.tsx
msgid "Decrease auto scroll speed" msgid "Decrease auto scroll speed"
msgstr "Decrease auto scroll speed" msgstr "Decrease auto scroll speed"
@@ -3361,6 +3364,10 @@ msgstr "Settings"
msgid "Share" msgid "Share"
msgstr "Share" msgstr "Share"
#: src/features/settings/components/DebugInformation.tsx
msgid "Show"
msgstr "Show"
#: src/features/library/screens/LibrarySettings.tsx #: src/features/library/screens/LibrarySettings.tsx
msgid "Show all duplicated entries in your library" msgid "Show all duplicated entries in your library"
msgstr "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 { CombinedGraphQLErrors } from '@apollo/client';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { makeToast } from '@/base/utils/Toast.ts';
export const jsonSaveParse = <T = any>(...args: Parameters<typeof JSON.parse>): T | null => { export const jsonSaveParse = <T = any>(...args: Parameters<typeof JSON.parse>): T | null => {
try { try {
@@ -95,3 +96,37 @@ export const maybeExecuteWithDelay = (
action(); action();
return undefined; 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, id: args?.id,
}); });
}, },
chapter(_, { args, toReference }) {
return toReference({
__typename: 'ChapterType',
id: args?.id,
});
},
category(_, { args, toReference }) { category(_, { args, toReference }) {
return toReference({ return toReference({
__typename: 'CategoryType', __typename: 'CategoryType',
@@ -136,6 +142,12 @@ const typePolicies: TypedTypePolicies = {
lastSyncStatus(_, { toReference }) { lastSyncStatus(_, { toReference }) {
return toReference({ __typename: 'SyncStatus', key: {} }); return toReference({ __typename: 'SyncStatus', key: {} });
}, },
tracker(_, { toReference }) {
return toReference({ __typename: 'TrackerType', key: {} });
},
trackRecord(_, { toReference }) {
return toReference({ __typename: 'TrackRecordType', key: {} });
},
mangas: { mangas: {
keyArgs: ['condition', 'filter', 'orderBy', 'orderByType', 'order'], keyArgs: ['condition', 'filter', 'orderBy', 'orderByType', 'order'],
merge(existing, incoming, { mergeObjects }) { 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", "uiVersion": "PREVIEW",
"serverVersion": "r2243" "serverVersion": "r2243"
}, },
{
"tag": "v20260722.01",
"uiVersion": "r3355",
"serverVersion": "r2243"
},
{ {
"tag": "v20260509.01", "tag": "v20260509.01",
"uiVersion": "r3147", "uiVersion": "r3147",