Compare commits
10 Commits
4658fe2073
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a8e4b6e658 | |||
| 4edc660606 | |||
| a0fd55eb37 | |||
| 7a9520be9e | |||
| b62c7e6f31 | |||
| f3893d98d4 | |||
|
|
fcc6df37ca | ||
|
|
d4fab13e0a | ||
|
|
103dd7c9c9 | ||
|
|
68bdcb0beb |
60
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
60
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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
|
||||
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. -->
|
||||
|
||||
- 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"
|
||||
```
|
||||
Replace this text with the copied debug information
|
||||
```
|
||||
</details>
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
53
CHANGELOG.md
53
CHANGELOG.md
@@ -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
|
||||
|
||||
49
docs/material-you-maintenance.md
Normal file
49
docs/material-you-maintenance.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
16
src/features/navigation-bar/MaterialYouNavigation.ts
Normal file
16
src/features/navigation-bar/MaterialYouNavigation.ts
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
>
|
||||
|
||||
448
src/features/settings/components/DebugInformation.tsx
Normal file
448
src/features/settings/components/DebugInformation.tsx
Normal 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>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
25
src/features/theme/services/MaterialYouNavigation.test.ts
Normal file
25
src/features/theme/services/MaterialYouNavigation.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
45
src/features/theme/services/MaterialYouPalette.test.ts
Normal file
45
src/features/theme/services/MaterialYouPalette.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
104
src/features/theme/services/MaterialYouPalette.ts
Normal file
104
src/features/theme/services/MaterialYouPalette.ts
Normal 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',
|
||||
};
|
||||
};
|
||||
22
src/features/theme/services/MaterialYouRevision.test.ts
Normal file
22
src/features/theme/services/MaterialYouRevision.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
15
src/features/theme/services/MaterialYouRevision.ts
Normal file
15
src/features/theme/services/MaterialYouRevision.ts
Normal 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`;
|
||||
};
|
||||
43
src/features/theme/services/MaterialYouThemeOptions.test.ts
Normal file
43
src/features/theme/services/MaterialYouThemeOptions.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
165
src/features/theme/services/MaterialYouThemeOptions.ts
Normal file
165
src/features/theme/services/MaterialYouThemeOptions.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
30
src/features/theme/services/MaterialYouVisualTokens.test.ts
Normal file
30
src/features/theme/services/MaterialYouVisualTokens.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
src/features/theme/services/MaterialYouVisualTokens.ts
Normal file
19
src/features/theme/services/MaterialYouVisualTokens.ts
Normal 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;
|
||||
@@ -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 ?? ''}
|
||||
}
|
||||
`,
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
16
tools/scripts/writeWebUIRevision.ts
Normal file
16
tools/scripts/writeWebUIRevision.ts
Normal 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');
|
||||
@@ -3,6 +3,11 @@
|
||||
"uiVersion": "PREVIEW",
|
||||
"serverVersion": "r2243"
|
||||
},
|
||||
{
|
||||
"tag": "v20260722.01",
|
||||
"uiVersion": "r3355",
|
||||
"serverVersion": "r2243"
|
||||
},
|
||||
{
|
||||
"tag": "v20260509.01",
|
||||
"uiVersion": "r3147",
|
||||
|
||||
Reference in New Issue
Block a user