Move "core" folder out of "features" into "base"

This commit is contained in:
schroda
2025-08-16 02:48:46 +02:00
parent f4c06d474d
commit 9e5af57a5f
311 changed files with 712 additions and 727 deletions

View File

@@ -0,0 +1,208 @@
/*
* 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 { SourceType } from '@/lib/graphql/generated/graphql.ts';
import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
import { ChapterSourceOrderInfo } from '@/features/chapter/Chapter.types.ts';
import { BrowseTab } from '@/features/browse/Browse.types.ts';
import { SearchParam } from '@/base/Base.types.ts';
type AppRouteInfo = {
match: string;
path?: string | ((...args: any[]) => string);
};
type TAppRoutes = Record<string, AppRouteInfo & { childRoutes?: TAppRoutes }>;
const createParam = (name: string, value: any): string => (value ? `${name}=${encodeURIComponent(value)}` : '');
const createQueryParam = (query: string | null | undefined): string => createParam(SearchParam.QUERY, query);
const addParams = (path: string, ...params: string[]) => {
const joinedParams = params.filter(Boolean).join('&');
return `${path}${joinedParams ? `?${joinedParams}` : ''}`;
};
export const AppRoutes = {
root: {
match: '/',
path: '/',
},
matchAll: {
match: '*',
},
about: {
match: 'about',
path: '/about',
},
settings: {
path: '/settings',
match: 'settings',
childRoutes: {
categories: {
match: 'categories',
path: '/settings/categories',
},
reader: {
match: 'reader',
path: '/settings/reader',
},
library: {
match: 'library',
path: '/settings/library',
childRoutes: {
duplicates: {
match: 'duplicates',
path: '/settings/library/duplicates',
},
},
},
download: {
match: 'download',
path: '/settings/download',
},
backup: {
match: 'backup',
path: '/settings/backup',
},
server: {
match: 'server',
path: '/settings/server',
},
webui: {
match: 'webui',
path: '/settings/webui',
},
browse: {
match: 'browse',
path: '/settings/browse',
},
device: {
match: 'device',
path: '/settings/device',
},
tracking: {
match: 'tracking',
path: '/settings/tracking',
},
appearance: {
match: 'appearance',
path: '/settings/appearance',
},
history: {
match: 'history',
path: '/settings/history',
},
},
},
sources: {
match: 'sources',
path: '/sources',
childRoutes: {
browse: {
match: ':sourceId',
path: (sourceId: SourceType['id'], query?: string | null | undefined) =>
addParams(`/sources/${sourceId}`, createQueryParam(query)),
},
configure: {
match: ':sourceId/configure',
path: (sourceId: SourceType['id']) => `/sources/${sourceId}/configure`,
},
searchAll: {
match: 'all/search',
path: (query?: string | null | undefined) => addParams('/sources/all/search', createQueryParam(query)),
},
},
},
extension: {
match: 'extension',
path: '/extension',
childRoutes: {
info: {
match: ':pkgName',
path: (pkgName: string) => `/extension/${pkgName}`,
},
},
},
downloads: {
match: 'downloads',
path: '/downloads',
},
manga: {
match: 'manga/:id',
path: (mangaId: MangaIdInfo['id']) => `/manga/${mangaId}`,
childRoutes: {
reader: {
match: 'chapter/:chapterNum',
path: (mangaId: MangaIdInfo['id'], chapterNum: ChapterSourceOrderInfo['sourceOrder']) =>
`/manga/${mangaId}/chapter/${chapterNum}`,
},
},
},
library: {
match: 'library',
path: (tab?: string, search?: string) =>
addParams('/library', createParam(SearchParam.TAB, tab), createQueryParam(search)),
},
updates: {
match: 'updates',
path: '/updates',
},
history: {
match: 'history',
path: '/history',
},
recent: {
match: 'recent',
path: '/recent',
},
browse: {
match: 'browse',
path: (tab?: BrowseTab) => addParams('/browse', createParam(SearchParam.TAB, tab)),
},
migrate: {
match: 'migrate/source/:sourceId',
path: (sourceId: SourceType['id']) => `/migrate/source/${sourceId}`,
childRoutes: {
search: {
match: 'manga/:mangaId/search',
path: (sourceId: SourceType['id'], mangaId: MangaIdInfo['id'], query?: string | null | undefined) =>
addParams(`/migrate/source/${sourceId}/manga/${mangaId}/search`, createQueryParam(query)),
},
},
},
tracker: {
match: 'tracker/login/oauth',
path: '/tracker/login/oauth',
},
reader: {
match: '/manga/:mangaId/chapter/:chapterSourceOrder/*',
path: (mangaId: MangaIdInfo['id'], chapterSourceOrder: ChapterSourceOrderInfo['sourceOrder']) =>
`/manga/${mangaId}/chapter/${chapterSourceOrder}`,
},
more: {
match: '/more',
path: '/more',
},
} as const satisfies TAppRoutes;
type ExtractChildRouteStringPaths<T> = T extends { childRoutes: infer U } ? ExtractStringPaths<U[keyof U]> : never;
type ExtractStringPaths<T> = T extends { path: infer P }
? P extends string
? P | ExtractChildRouteStringPaths<T>
: ExtractChildRouteStringPaths<T>
: ExtractChildRouteStringPaths<T>;
export type StaticAppRoute = ExtractStringPaths<(typeof AppRoutes)[keyof typeof AppRoutes]>;

15
src/base/Asserts.ts Normal file
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/.
*/
import { AssertionError } from 'assert';
export function assertIsDefined<T>(value: T | undefined): asserts value is NonNullable<T> {
if (value === undefined || value === null) {
throw new AssertionError({ message: 'Value is undefined or null' });
}
}

76
src/base/Base.types.ts Normal file
View File

@@ -0,0 +1,76 @@
/*
* 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 { ReactNode } from 'react';
import { ParseKeys } from 'i18next';
export enum GridLayout {
Compact = 0,
Comfortable = 1,
List = 2,
}
export enum DirectionOffset {
PREVIOUS = -1,
NEXT = 1,
}
export type NullAndUndefined<T> = T | null | undefined;
export type TranslationKey = ParseKeys;
interface DisplayDataTranslationKey {
isTitleString?: never;
title: TranslationKey;
icon: ReactNode;
}
interface DisplayDataString {
isTitleString: true;
title: string;
icon: ReactNode;
}
type DisplayData = DisplayDataTranslationKey | DisplayDataString;
export type ValueToDisplayData<Value extends string | number> = Record<Value, DisplayData>;
export interface MultiValueButtonBaseProps<Value extends string | number> {
tooltip?: string;
value: Value;
defaultValue?: Value;
values: Value[];
setValue: (value: Value) => void;
valueToDisplayData: ValueToDisplayData<Value>;
}
export interface MultiValueButtonDefaultableProps<Value extends string | number>
extends OptionalProperty<MultiValueButtonBaseProps<Value>, 'value'> {
isDefaultable?: boolean;
onDefault?: () => void;
}
export type MultiValueButtonProps<Value extends string | number> =
| (MultiValueButtonBaseProps<Value> & PropertiesNever<MultiValueButtonDefaultableProps<Value>>)
| MultiValueButtonDefaultableProps<Value>;
export enum ScrollOffset {
BACKWARD,
FORWARD,
}
export enum ScrollDirection {
X,
Y,
XY,
}
export enum SearchParam {
TAB = 'tab',
QUERY = 'query',
}

805
src/base/IsoLanguages.ts Normal file
View File

@@ -0,0 +1,805 @@
/*
* 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 { I18nResourceCode } from '@/i18n';
export type ISOLanguage = {
name: string;
nativeName: string;
};
const PT_BR: ISOLanguage = {
name: 'Portuguese; Brasil',
nativeName: 'Português (Brasil)',
};
// full list: https://github.com/meikidd/iso-639-1/blob/master/src/data.js
export const IsoLanguages: { [languageCode in I18nResourceCode]: ISOLanguage } & Record<string, ISOLanguage> = {
// #############################
// ### ###
// ### START: manually added ###
// ### ###
// #############################
'es-419': {
name: 'Spanish; Castilian',
nativeName: 'Español (Latinoamérica)',
},
'pt-pt': {
name: 'Portuguese',
nativeName: 'Português (Portugal)',
},
'pt-br': PT_BR,
'pt-BR': PT_BR,
zh_Hans: {
name: 'Chinese (Simplified)',
nativeName: '中文 (HANS)',
},
zh_Hant: {
name: 'Chinese (Traditional)',
nativeName: '中文 (HANT)',
},
'zh-rhk': {
name: 'Chinese',
nativeName: '中文 (RHK)',
},
'zh-rtw': {
name: 'Chinese',
nativeName: '中文 (RTW)',
},
fil: {
name: 'Filipino',
nativeName: 'Filipino',
},
sh: {
name: 'Serbo-Croatian',
nativeName: 'srpskohrvatski',
},
'nb-NO': {
name: 'Norwegian Bokmål',
nativeName: 'Norsk bokmål',
},
// #############################
// ### ###
// ### END: manually added ###
// ### ###
// #############################
aa: {
name: 'Afar',
nativeName: 'Afaraf',
},
ab: {
name: 'Abkhaz',
nativeName: 'аҧсуа бызшәа',
},
ae: {
name: 'Avestan',
nativeName: 'avesta',
},
af: {
name: 'Afrikaans',
nativeName: 'Afrikaans',
},
ak: {
name: 'Akan',
nativeName: 'Akan',
},
am: {
name: 'Amharic',
nativeName: 'አማርኛ',
},
an: {
name: 'Aragonese',
nativeName: 'aragonés',
},
ar: {
name: 'Arabic',
nativeName: 'اَلْعَرَبِيَّةُ',
},
as: {
name: 'Assamese',
nativeName: 'অসমীয়া',
},
av: {
name: 'Avaric',
nativeName: 'авар мацӀ',
},
ay: {
name: 'Aymara',
nativeName: 'aymar aru',
},
az: {
name: 'Azerbaijani',
nativeName: 'azərbaycan dili',
},
ba: {
name: 'Bashkir',
nativeName: 'башҡорт теле',
},
be: {
name: 'Belarusian',
nativeName: 'беларуская мова',
},
bg: {
name: 'Bulgarian',
nativeName: 'български език',
},
bi: {
name: 'Bislama',
nativeName: 'Bislama',
},
bm: {
name: 'Bambara',
nativeName: 'bamanankan',
},
bn: {
name: 'Bengali',
nativeName: 'বাংলা',
},
bo: {
name: 'Tibetan',
nativeName: 'བོད་ཡིག',
},
br: {
name: 'Breton',
nativeName: 'brezhoneg',
},
bs: {
name: 'Bosnian',
nativeName: 'bosanski jezik',
},
ca: {
name: 'Catalan',
nativeName: 'Català',
},
ce: {
name: 'Chechen',
nativeName: 'нохчийн мотт',
},
ch: {
name: 'Chamorro',
nativeName: 'Chamoru',
},
co: {
name: 'Corsican',
nativeName: 'corsu',
},
cr: {
name: 'Cree',
nativeName: 'ᓀᐦᐃᔭᐍᐏᐣ',
},
cs: {
name: 'Czech',
nativeName: 'čeština',
},
cu: {
name: 'Old Church Slavonic',
nativeName: 'ѩзыкъ словѣньскъ',
},
cv: {
name: 'Chuvash',
nativeName: 'чӑваш чӗлхи',
},
cy: {
name: 'Welsh',
nativeName: 'Cymraeg',
},
da: {
name: 'Danish',
nativeName: 'Dansk',
},
de: {
name: 'German',
nativeName: 'Deutsch',
},
dv: {
name: 'Divehi',
nativeName: 'ދިވެހި',
},
dz: {
name: 'Dzongkha',
nativeName: 'རྫོང་ཁ',
},
ee: {
name: 'Ewe',
nativeName: 'Eʋegbe',
},
el: {
name: 'Greek',
nativeName: 'Ελληνικά',
},
en: {
name: 'English',
nativeName: 'English',
},
eo: {
name: 'Esperanto',
nativeName: 'Esperanto',
},
es: {
name: 'Spanish',
nativeName: 'Español',
},
et: {
name: 'Estonian',
nativeName: 'eesti',
},
eu: {
name: 'Basque',
nativeName: 'euskara',
},
fa: {
name: 'Persian',
nativeName: 'فارسی',
},
ff: {
name: 'Fula',
nativeName: 'Fulfulde',
},
fi: {
name: 'Finnish',
nativeName: 'suomi',
},
fj: {
name: 'Fijian',
nativeName: 'vosa Vakaviti',
},
fo: {
name: 'Faroese',
nativeName: 'Føroyskt',
},
fr: {
name: 'French',
nativeName: 'Français',
},
fy: {
name: 'Western Frisian',
nativeName: 'Frysk',
},
ga: {
name: 'Irish',
nativeName: 'Gaeilge',
},
gd: {
name: 'Scottish Gaelic',
nativeName: 'Gàidhlig',
},
gl: {
name: 'Galician',
nativeName: 'galego',
},
gn: {
name: 'Guaraní',
nativeName: "Avañe'ẽ",
},
gu: {
name: 'Gujarati',
nativeName: 'ગુજરાતી',
},
gv: {
name: 'Manx',
nativeName: 'Gaelg',
},
ha: {
name: 'Hausa',
nativeName: 'هَوُسَ',
},
he: {
name: 'Hebrew',
nativeName: 'עברית',
},
hi: {
name: 'Hindi',
nativeName: 'हिन्दी',
},
ho: {
name: 'Hiri Motu',
nativeName: 'Hiri Motu',
},
hr: {
name: 'Croatian',
nativeName: 'Hrvatski',
},
ht: {
name: 'Haitian',
nativeName: 'Kreyòl ayisyen',
},
hu: {
name: 'Hungarian',
nativeName: 'magyar',
},
hy: {
name: 'Armenian',
nativeName: 'Հայերեն',
},
hz: {
name: 'Herero',
nativeName: 'Otjiherero',
},
ia: {
name: 'Interlingua',
nativeName: 'Interlingua',
},
id: {
name: 'Indonesian',
nativeName: 'Bahasa Indonesia',
},
ie: {
name: 'Interlingue',
nativeName: 'Interlingue',
},
ig: {
name: 'Igbo',
nativeName: 'Asụsụ Igbo',
},
ii: {
name: 'Nuosu',
nativeName: 'ꆈꌠ꒿ Nuosuhxop',
},
ik: {
name: 'Inupiaq',
nativeName: 'Iñupiaq',
},
io: {
name: 'Ido',
nativeName: 'Ido',
},
is: {
name: 'Icelandic',
nativeName: 'Íslenska',
},
it: {
name: 'Italian',
nativeName: 'Italiano',
},
iu: {
name: 'Inuktitut',
nativeName: 'ᐃᓄᒃᑎᑐᑦ',
},
ja: {
name: 'Japanese',
nativeName: '日本語',
},
jv: {
name: 'Javanese',
nativeName: 'basa Jawa',
},
ka: {
name: 'Georgian',
nativeName: 'ქართული',
},
kg: {
name: 'Kongo',
nativeName: 'Kikongo',
},
ki: {
name: 'Kikuyu',
nativeName: 'Gĩkũyũ',
},
kj: {
name: 'Kwanyama',
nativeName: 'Kuanyama',
},
kk: {
name: 'Kazakh',
nativeName: 'қазақ тілі',
},
kl: {
name: 'Kalaallisut',
nativeName: 'kalaallisut',
},
km: {
name: 'Khmer',
nativeName: 'ខេមរភាសា',
},
kn: {
name: 'Kannada',
nativeName: 'ಕನ್ನಡ',
},
ko: {
name: 'Korean',
nativeName: '한국어',
},
kr: {
name: 'Kanuri',
nativeName: 'Kanuri',
},
ks: {
name: 'Kashmiri',
nativeName: 'कश्मीरी',
},
ku: {
name: 'Kurdish',
nativeName: 'Kurdî',
},
kv: {
name: 'Komi',
nativeName: 'коми кыв',
},
kw: {
name: 'Cornish',
nativeName: 'Kernewek',
},
ky: {
name: 'Kyrgyz',
nativeName: 'Кыргызча',
},
la: {
name: 'Latin',
nativeName: 'latine',
},
lb: {
name: 'Luxembourgish',
nativeName: 'Lëtzebuergesch',
},
lg: {
name: 'Ganda',
nativeName: 'Luganda',
},
li: {
name: 'Limburgish',
nativeName: 'Limburgs',
},
ln: {
name: 'Lingala',
nativeName: 'Lingála',
},
lo: {
name: 'Lao',
nativeName: 'ພາສາລາວ',
},
lt: {
name: 'Lithuanian',
nativeName: 'lietuvių kalba',
},
lu: {
name: 'Luba-Katanga',
nativeName: 'Kiluba',
},
lv: {
name: 'Latvian',
nativeName: 'latviešu valoda',
},
mg: {
name: 'Malagasy',
nativeName: 'fiteny malagasy',
},
mh: {
name: 'Marshallese',
nativeName: 'Kajin M̧ajeļ',
},
mi: {
name: 'Māori',
nativeName: 'te reo Māori',
},
mk: {
name: 'Macedonian',
nativeName: 'македонски јазик',
},
ml: {
name: 'Malayalam',
nativeName: 'മലയാളം',
},
mn: {
name: 'Mongolian',
nativeName: 'Монгол хэл',
},
mr: {
name: 'Marathi',
nativeName: 'मराठी',
},
ms: {
name: 'Malay',
nativeName: 'Bahasa Melayu',
},
mt: {
name: 'Maltese',
nativeName: 'Malti',
},
my: {
name: 'Burmese',
nativeName: 'ဗမာစာ',
},
na: {
name: 'Nauru',
nativeName: 'Dorerin Naoero',
},
nb: {
name: 'Norwegian Bokmål',
nativeName: 'Norsk bokmål',
},
nd: {
name: 'Northern Ndebele',
nativeName: 'isiNdebele',
},
ne: {
name: 'Nepali',
nativeName: 'नेपाली',
},
ng: {
name: 'Ndonga',
nativeName: 'Owambo',
},
nl: {
name: 'Dutch',
nativeName: 'Nederlands',
},
nn: {
name: 'Norwegian Nynorsk',
nativeName: 'Norsk nynorsk',
},
no: {
name: 'Norwegian',
nativeName: 'Norsk',
},
nr: {
name: 'Southern Ndebele',
nativeName: 'isiNdebele',
},
nv: {
name: 'Navajo',
nativeName: 'Diné bizaad',
},
ny: {
name: 'Chichewa',
nativeName: 'chiCheŵa',
},
oc: {
name: 'Occitan',
nativeName: 'occitan',
},
oj: {
name: 'Ojibwe',
nativeName: 'ᐊᓂᔑᓈᐯᒧᐎᓐ',
},
om: {
name: 'Oromo',
nativeName: 'Afaan Oromoo',
},
or: {
name: 'Oriya',
nativeName: 'ଓଡ଼ିଆ',
},
os: {
name: 'Ossetian',
nativeName: 'ирон æвзаг',
},
pa: {
name: 'Panjabi',
nativeName: 'ਪੰਜਾਬੀ',
},
pi: {
name: 'Pāli',
nativeName: 'पाऴि',
},
pl: {
name: 'Polish',
nativeName: 'Polski',
},
ps: {
name: 'Pashto',
nativeName: 'پښتو',
},
pt: {
name: 'Portuguese',
nativeName: 'Português',
},
qu: {
name: 'Quechua',
nativeName: 'Runa Simi',
// asdfasdfasdfasdf
},
rm: {
name: 'Romansh',
nativeName: 'rumantsch grischun',
},
rn: {
name: 'Kirundi',
nativeName: 'Ikirundi',
},
ro: {
name: 'Romanian',
nativeName: 'Română',
},
ru: {
name: 'Russian',
nativeName: 'Русский',
},
rw: {
name: 'Kinyarwanda',
nativeName: 'Ikinyarwanda',
},
sa: {
name: 'Sanskrit',
nativeName: 'संस्कृतम्',
},
sc: {
name: 'Sardinian',
nativeName: 'sardu',
},
sd: {
name: 'Sindhi',
nativeName: 'सिन्धी',
},
se: {
name: 'Northern Sami',
nativeName: 'Davvisámegiella',
},
sg: {
name: 'Sango',
nativeName: 'yângâ tî sängö',
},
si: {
name: 'Sinhala',
nativeName: 'සිංහල',
},
sk: {
name: 'Slovak',
nativeName: 'slovenčina',
},
sl: {
name: 'Slovenian',
nativeName: 'slovenščina',
},
sm: {
name: 'Samoan',
nativeName: "gagana fa'a Samoa",
},
sn: {
name: 'Shona',
nativeName: 'chiShona',
},
so: {
name: 'Somali',
nativeName: 'Soomaaliga',
},
sq: {
name: 'Albanian',
nativeName: 'Shqip',
},
sr: {
name: 'Serbian',
nativeName: 'српски језик',
},
ss: {
name: 'Swati',
nativeName: 'SiSwati',
},
st: {
name: 'Southern Sotho',
nativeName: 'Sesotho',
},
su: {
name: 'Sundanese',
nativeName: 'Basa Sunda',
},
sv: {
name: 'Swedish',
nativeName: 'Svenska',
},
sw: {
name: 'Swahili',
nativeName: 'Kiswahili',
},
ta: {
name: 'Tamil',
nativeName: 'தமிழ்',
},
te: {
name: 'Telugu',
nativeName: 'తెలుగు',
},
tg: {
name: 'Tajik',
nativeName: 'тоҷикӣ',
},
th: {
name: 'Thai',
nativeName: 'ไทย',
},
ti: {
name: 'Tigrinya',
nativeName: 'ትግርኛ',
},
tk: {
name: 'Turkmen',
nativeName: 'Türkmençe',
},
tl: {
name: 'Tagalog',
nativeName: 'Wikang Tagalog',
},
tn: {
name: 'Tswana',
nativeName: 'Setswana',
},
to: {
name: 'Tonga',
nativeName: 'faka Tonga',
},
tr: {
name: 'Turkish',
nativeName: 'Türkçe',
},
ts: {
name: 'Tsonga',
nativeName: 'Xitsonga',
},
tt: {
name: 'Tatar',
nativeName: 'татар теле',
},
tw: {
name: 'Twi',
nativeName: 'Twi',
},
ty: {
name: 'Tahitian',
nativeName: 'Reo Tahiti',
},
ug: {
name: 'Uyghur',
nativeName: 'ئۇيغۇرچە‎',
},
uk: {
name: 'Ukrainian',
nativeName: 'Українська',
},
ur: {
name: 'Urdu',
nativeName: 'اردو',
},
uz: {
name: 'Uzbek',
nativeName: 'Ўзбек',
},
ve: {
name: 'Venda',
nativeName: 'Tshivenḓa',
},
vi: {
name: 'Vietnamese',
nativeName: 'Tiếng Việt',
},
vo: {
name: 'Volapük',
nativeName: 'Volapük',
},
wa: {
name: 'Walloon',
nativeName: 'walon',
},
wo: {
name: 'Wolof',
nativeName: 'Wollof',
},
xh: {
name: 'Xhosa',
nativeName: 'isiXhosa',
},
yi: {
name: 'Yiddish',
nativeName: 'ייִדיש',
},
yo: {
name: 'Yoruba',
nativeName: 'Yorùbá',
},
za: {
name: 'Zhuang',
nativeName: 'Saɯ cueŋƅ',
},
zh: {
name: 'Chinese',
nativeName: '中文',
},
zu: {
name: 'Zulu',
nativeName: 'isiZulu',
},
};

View File

@@ -0,0 +1,128 @@
/*
* 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 React, { useState } from 'react';
import SearchIcon from '@mui/icons-material/Search';
import IconButton from '@mui/material/IconButton';
import { useQueryParam, StringParam } from 'use-query-params';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { useTheme } from '@mui/material/styles';
import { useHotkeys } from 'react-hotkeys-hook';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import { SearchTextField } from '@/base/components/inputs/SearchTextField.tsx';
import { SearchParam } from '@/base/Base.types.ts';
interface IProps {
isClosable?: boolean;
}
export const AppbarSearch: React.FunctionComponent<IProps> = (props) => {
const { isClosable = true } = props;
const theme = useTheme();
const { t } = useTranslation();
const [prevLocationKey, setPrevLocationKey] = useState<string>();
const location = useLocation();
const [query, setQuery] = useQueryParam(SearchParam.QUERY, StringParam);
const [isSearchOpen, setIsSearchOpen] = useState(!isClosable || !!query);
const inputRef = React.useRef<HTMLInputElement>(undefined);
const [searchString, setSearchString] = useState(query ?? '');
if (prevLocationKey !== location.key) {
setPrevLocationKey(location.key);
setSearchString(query ?? '');
setIsSearchOpen(!isClosable || !!query);
}
const isOpen = isSearchOpen || !!query;
const updateSearchOpenState = (open: boolean) => {
if (!isClosable) {
return;
}
setIsSearchOpen(open);
// try to focus input component since in case of navigating to the previous/next page in the browser history
// the "openSearch" state might not change and thus, won't trigger a focus
if (open) {
inputRef.current?.focus();
}
};
function handleChange(newQuery: string) {
if (newQuery === '') {
return;
}
setQuery(newQuery);
updateSearchOpenState(false);
}
const cancelSearch = () => {
setSearchString('');
setQuery(undefined);
updateSearchOpenState(false);
};
const handleBlur = () => {
if (!searchString) updateSearchOpenState(false);
};
useHotkeys(
'ctrl+f, F3',
() => {
updateSearchOpenState(true);
},
{ preventDefault: true },
);
if (isOpen) {
return (
<SearchTextField
autoFocus
variant="standard"
value={searchString}
onCancel={cancelSearch}
onChange={(e) => setSearchString(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleChange(searchString);
}
}}
onBlur={handleBlur}
inputRef={inputRef}
sx={{
...theme.applyStyles('light', {
'& .MuiInput-underline:before': {
borderBottomColor: 'primary.contrastText', // Default color
},
'& .MuiInput-underline:hover:before': {
borderBottomColor: 'primary.contrastText', // Hover color
},
'& .MuiInput-underline:after': {
borderBottomColor: 'primary.dark', // Focused color
},
}),
}}
cancelButtonProps={{ sx: { ...theme.applyStyles('light', { color: 'primary.contrastText' }) } }}
/>
);
}
return (
<CustomTooltip title={t('search.title.search')}>
<IconButton onClick={() => updateSearchOpenState(true)} color="inherit">
<SearchIcon />
</IconButton>
</CustomTooltip>
);
};

View File

@@ -0,0 +1,20 @@
/*
* 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 Tooltip, { TooltipProps } from '@mui/material/Tooltip';
export const CustomTooltip = ({
children,
disabled = false,
title,
...props
}: TooltipProps & { disabled?: boolean }) => (
<Tooltip {...props} title={disabled ? '' : title}>
{children}
</Tooltip>
);

View File

@@ -0,0 +1,105 @@
/*
* 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 IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import FormControlLabel from '@mui/material/FormControlLabel';
import Radio from '@mui/material/Radio';
import React from 'react';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
import { useTranslation } from 'react-i18next';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import { GridLayout } from '@/base/Base.types.ts';
// TODO: clean up this to use a FormControl, and remove dependency on name o radio button
export function GridLayouts({
gridLayout,
onChange,
}: {
gridLayout: GridLayout;
onChange: (gridLayout: GridLayout) => void;
}) {
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = React.useState(null);
const open = Boolean(anchorEl);
const handleClick = (event: any) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
onChange(parseInt(e.target.name, 10));
}
return (
<>
<CustomTooltip title={t('global.label.display')}>
<IconButton
onClick={handleClick}
size="small"
aria-controls={open ? 'account-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
color="inherit"
>
<ViewModuleIcon />
</IconButton>
</CustomTooltip>
<Menu
id="basic-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{ 'aria-labelledby': 'basic-button' }}
>
<MenuItem onClick={handleClose}>
<FormControlLabel
label={t('global.grid_layout.label.compact_grid')}
value={GridLayout.Compact}
control={
<Radio
name={GridLayout.Compact.toString()}
checked={gridLayout === GridLayout.Compact}
onChange={handleChange}
/>
}
/>
</MenuItem>
<MenuItem onClick={handleClose}>
<FormControlLabel
label={t('global.grid_layout.label.comfortable_grid')}
control={
<Radio
name={GridLayout.Comfortable.toString()}
checked={gridLayout === GridLayout.Comfortable}
onChange={handleChange}
/>
}
/>
</MenuItem>
<MenuItem onClick={handleClose}>
<FormControlLabel
label={t('global.grid_layout.label.list')}
control={
<Radio
name={GridLayout.List.toString()}
checked={gridLayout === GridLayout.List}
onChange={handleChange}
/>
}
/>
</MenuItem>
</Menu>
</>
);
}

View File

@@ -0,0 +1,225 @@
/*
* 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 { useState, useEffect, forwardRef, ForwardedRef, useCallback, useRef } from 'react';
import CircularProgress from '@mui/material/CircularProgress';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';
import BrokenImageIcon from '@mui/icons-material/BrokenImage';
import RefreshIcon from '@mui/icons-material/Refresh';
import { useTranslation } from 'react-i18next';
import ImageIcon from '@mui/icons-material/Image';
import { SxProps, Theme } from '@mui/material/styles';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { Priority } from '@/lib/Queue.ts';
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
import { useIntersectionObserver } from '@/base/hooks/useIntersectionObserver.tsx';
export interface SpinnerImageProps {
shouldLoad?: boolean;
src: string;
alt: string;
spinnerStyle?: SxProps<Theme> & { small?: boolean };
imgStyle?: SxProps<Theme>;
hideImgStyle?: Omit<SxProps<Theme>, 'accentColor'>;
onLoad?: () => void;
onError?: () => void;
shouldDecode?: boolean;
useFetchApi?: boolean;
disableCors?: boolean;
priority?: Priority;
retryKeyPrefix?: string;
}
export const SpinnerImage = forwardRef(
(props: SpinnerImageProps, imgRef: ForwardedRef<HTMLImageElement | HTMLDivElement | null>) => {
const {
shouldLoad = true,
shouldDecode,
useFetchApi,
disableCors,
src,
alt,
onLoad,
onError,
spinnerStyle: { small, ...spinnerStyle } = {},
imgStyle,
hideImgStyle,
priority,
retryKeyPrefix,
} = props;
const { t } = useTranslation();
const loadingIndicatorRef = useRef<HTMLDivElement | null>(null);
const showMissingImageIcon = !src.length;
const [imageSourceUrl, setImageSourceUrl] = useState<string>();
const [imgLoadRetryKey, setImgLoadRetryKey] = useState(0);
const [isLoading, setIsLoading] = useState<boolean>();
const [hasError, setHasError] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const updateImageState = (loading: boolean, error: boolean = false, aborted: boolean = false) => {
setIsLoading(loading);
setHasError(error);
if (error && !loading && !aborted) {
onError?.();
}
if (!loading && !error && !aborted) {
onLoad?.();
}
};
useIntersectionObserver(
loadingIndicatorRef,
useCallback((entries) => setIsVisible(entries[0].isIntersecting), []),
);
useEffect(() => {
if (showMissingImageIcon || !shouldLoad) {
return () => {};
}
const imageRequest = requestManager.requestImage(src, {
priority,
shouldDecode,
useFetchApi,
disableCors,
});
let cacheTimeout: NodeJS.Timeout;
const fetchImage = async () => {
try {
const updateImage = async () => {
const image = await imageRequest.response;
updateImageState(false);
setImageSourceUrl(image);
};
const checkCache = await Promise.race([
imageRequest.response,
new Promise((resolve) => {
cacheTimeout = setTimeout(resolve, 50);
}),
]);
const isImageCached = !!checkCache;
if (isImageCached) {
await updateImage();
return;
}
updateImageState(true);
await updateImage();
} catch (e) {
const wasAborted =
e instanceof Error && (e.name === 'AbortError' || e.message === 'Component was unmounted');
updateImageState(false, !wasAborted, wasAborted);
}
};
fetchImage().catch(() => {});
return () => {
imageRequest.cleanup();
clearTimeout(cacheTimeout);
imageRequest.abortRequest(new Error('Component was unmounted'));
};
}, [src, imgLoadRetryKey, retryKeyPrefix, showMissingImageIcon, shouldLoad]);
return (
<>
{showMissingImageIcon ? (
<Stack
ref={imgRef}
sx={{
height: '100%',
alignItems: 'center',
justifyContent: 'center',
background: (theme) => theme.palette.background.default,
...spinnerStyle,
}}
>
<ImageIcon fontSize="large" />
</Stack>
) : (
<Box
component="img"
key={`${src}_${imgLoadRetryKey}_${retryKeyPrefix}`}
sx={[
...(Array.isArray(imgStyle) ? (imgStyle ?? []) : [imgStyle]),
applyStyles(!imageSourceUrl || isLoading || hasError, {
...hideImgStyle,
...applyStyles(!hideImgStyle, {
display: 'none',
}),
}),
]}
ref={imgRef}
crossOrigin={disableCors ? undefined : 'anonymous'}
src={imageSourceUrl}
alt={alt}
draggable={false}
/>
)}
{(isLoading || (src && !imageSourceUrl) || hasError) && (
<Stack
ref={loadingIndicatorRef}
sx={{
height: '100%',
justifyContent: 'center',
alignItems: 'center',
...spinnerStyle,
}}
>
<Stack
sx={{
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}
>
{isVisible && (isLoading || (src && !imageSourceUrl && !hasError)) && (
<CircularProgress thickness={5} />
)}
{hasError && isLoading === false && (
<>
<BrokenImageIcon />
<Button
startIcon={!small && <RefreshIcon />}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setImgLoadRetryKey((prevState) => (prevState + 1) % 100);
}}
size={small ? 'small' : 'large'}
>
{small ? <RefreshIcon /> : t('global.button.retry')}
</Button>
</>
)}
</Stack>
</Stack>
)}
</>
);
},
);

View File

@@ -0,0 +1,55 @@
/*
* 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 Stack from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import { MultiValueButtonProps } from '@/base/Base.types.ts';
import { Superscript } from '@/base/components/texts/Superscript.tsx';
export const ButtonSelect = <Value extends string | number>({
value,
values,
defaultValue,
setValue,
valueToDisplayData,
isDefaultable,
onDefault,
}: MultiValueButtonProps<Value>) => {
const { t } = useTranslation();
return (
<Stack sx={{ flexDirection: 'row', flexWrap: 'wrap', gap: 1 }}>
{isDefaultable && (
<Button key="default" onClick={onDefault} variant={value === undefined ? 'contained' : 'outlined'}>
{t('global.label.default')}
</Button>
)}
{values.map((displayValue) => {
const isDefault = value === undefined && displayValue === defaultValue;
const text = valueToDisplayData[displayValue].isTitleString
? valueToDisplayData[displayValue].title
: t(valueToDisplayData[displayValue].title);
return (
<CustomTooltip key={displayValue} title={isDefault ? t('reader.settings.active_setting') : ''}>
<Button
onClick={() => setValue(displayValue)}
variant={displayValue === value ? 'contained' : 'outlined'}
startIcon={valueToDisplayData[displayValue].icon}
>
{isDefault ? <Superscript i18nKey="global.label.footnote" value={text} /> : text}
</Button>
</CustomTooltip>
);
})}
</Stack>
);
};

View File

@@ -0,0 +1,32 @@
/*
* 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 Button, { ButtonProps } from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import { ForwardedRef, forwardRef } from 'react';
export const CustomButton = forwardRef(
<C extends React.ElementType>(
{ children, ...props }: ButtonProps<C, { component?: C }>,
ref: ForwardedRef<HTMLButtonElement | null>,
) => (
<Button ref={ref} {...props}>
<Stack
direction="row"
sx={{
alignItems: 'center',
justifyContent: 'center',
gap: 1,
flexWrap: 'wrap',
}}
>
{children}
</Stack>
</Button>
),
);

View File

@@ -0,0 +1,29 @@
/*
* 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 Button, { ButtonProps } from '@mui/material/Button';
import { ForwardedRef, forwardRef } from 'react';
export const CustomButtonIcon = forwardRef(
<C extends React.ElementType>(
{ children, ...props }: ButtonProps<C, { component?: C }>,
ref: ForwardedRef<HTMLButtonElement | null>,
) => (
<Button
ref={ref}
{...props}
sx={{
minWidth: 'unset',
px: '10px',
...props.sx,
}}
>
{children}
</Button>
),
);

View File

@@ -0,0 +1,37 @@
/*
* 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 Button, { ButtonProps } from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import IconButton, { IconButtonProps } from '@mui/material/IconButton';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
type PropsIconButton = { asIconButton: true } & IconButtonProps;
type PropsButton = { asIconButton?: false } & ButtonProps;
type Props = PropsIconButton | PropsButton;
export const ResetButton = ({ asIconButton, ...props }: Props) => {
const { t } = useTranslation();
if (asIconButton) {
return (
<CustomTooltip title={t('global.button.reset')}>
<IconButton color="inherit" {...props}>
<RestartAltIcon />
</IconButton>
</CustomTooltip>
);
}
return (
<Button startIcon={<RestartAltIcon />} {...(props as ButtonProps)}>
{t('global.button.reset')}
</Button>
);
};

View File

@@ -0,0 +1,23 @@
/*
* 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 Fab from '@mui/material/Fab';
import { styled } from '@mui/material/styles';
export const DEFAULT_FAB_STYLE = {
position: 'fixed',
height: '48px',
right: '48px',
bottom: '28px',
} as const;
export const DEFAULT_FULL_FAB_HEIGHT = `calc(${DEFAULT_FAB_STYLE.bottom} + ${DEFAULT_FAB_STYLE.height})`;
export const StyledFab = styled(Fab)({
...DEFAULT_FAB_STYLE,
}) as typeof Fab;

View File

@@ -0,0 +1,86 @@
/*
* 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 { useTranslation } from 'react-i18next';
import { ReactNode, useMemo } from 'react';
import Button from '@mui/material/Button';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import { MultiValueButtonProps } from '@/base/Base.types.ts';
import { getNextRotationValue } from '@/base/utils/ValueRotationButton.utils.ts';
import { Superscript } from '@/base/components/texts/Superscript.tsx';
export const ValueRotationButton = <Value extends string | number>({
tooltip,
value,
defaultValue,
values,
setValue,
valueToDisplayData,
isDefaultable,
onDefault,
defaultIcon,
}: MultiValueButtonProps<Value> & { defaultIcon?: ReactNode }) => {
const { t } = useTranslation();
const isDefault = value === undefined;
const indexOfValue = useMemo(() => {
if (isDefault) {
return -1;
}
return values.indexOf(value);
}, [value, values]);
return (
<CustomTooltip title={tooltip}>
{isDefault ? (
<Button
onClick={() => setValue(values[0])}
sx={{ justifyContent: 'start', textTransform: 'unset', flexGrow: 1 }}
variant="contained"
startIcon={defaultIcon}
size="large"
>
{defaultValue === undefined ? (
t('global.label.default')
) : (
<Superscript
i18nKey="settings.default_value"
value={
valueToDisplayData[defaultValue].isTitleString
? valueToDisplayData[defaultValue].title
: t(valueToDisplayData[defaultValue].title)
}
/>
)}
</Button>
) : (
<Button
onClick={() => {
const nextValue = getNextRotationValue(indexOfValue, values, isDefaultable);
if (nextValue === undefined) {
onDefault?.();
return;
}
setValue(nextValue);
}}
sx={{ justifyContent: 'start', textTransform: 'unset', flexGrow: 1 }}
variant="contained"
startIcon={valueToDisplayData[value].icon}
size="large"
>
{valueToDisplayData[value].isTitleString
? valueToDisplayData[value].title
: t(valueToDisplayData[value].title)}
</Button>
)}
</CustomTooltip>
);
};

View File

@@ -0,0 +1,74 @@
/*
* 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 CircularProgress from '@mui/material/CircularProgress';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import { DownloadState } from '@/lib/graphql/generated/graphql.ts';
import { Chapters } from '@/features/chapter/services/Chapters.ts';
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
import { TranslationKey } from '@/base/Base.types.ts';
const DOWNLOAD_STATE_TO_TRANSLATION_KEY_MAP: { [state in DownloadState]: TranslationKey } = {
DOWNLOADING: 'download.state.label.downloading',
ERROR: 'download.state.label.error',
FINISHED: 'download.state.label.finished',
QUEUED: 'download.state.label.queued',
} as const;
export const DownloadStateIndicator = ({ chapterId, color }: { chapterId: ChapterIdInfo['id']; color?: string }) => {
const { t } = useTranslation();
const download = Chapters.useDownloadStatusFromCache(chapterId);
if (!download) {
return null;
}
const isDownloading = download.state === DownloadState.Downloading;
const isPartiallyDownloaded = download.progress !== 0;
const progress = `${Math.round(download.progress * 100)}%`;
return (
<Box
sx={{
position: 'relative',
display: 'inline-flex',
width: '50px',
justifyContent: 'center',
}}
>
{isDownloading && <CircularProgress variant="determinate" value={download.progress * 100} sx={{ color }} />}
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant="caption" component="div" sx={{ color }}>
<>
{isDownloading && progress}
{!isDownloading &&
t('global.value', {
value: t(DOWNLOAD_STATE_TO_TRANSLATION_KEY_MAP[download.state]),
unit: isPartiallyDownloaded ? ` (${progress})` : '',
})}
</>
</Typography>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,123 @@
/*
* 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/.
*/
// adopted from: https://github.com/tachiyomiorg/tachiyomi/blob/master/app/src/main/java/eu/kanade/tachiyomi/widget/EmptyView.kt
import { type JSX, useMemo, useState } from 'react';
import Typography from '@mui/material/Typography';
import { SxProps, Theme } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import Collapse from '@mui/material/Collapse';
import { extractGraphqlExceptionInfo } from '@/lib/HelperFunctions.ts';
const ERROR_FACES = ['(・o・;)', 'Σ(ಠ_ಠ)', 'ಥ_ಥ', '(˘・_・˘)', '(; ̄Д ̄)', '(・Д・。'];
function getRandomErrorFace() {
const randIndex = Math.floor(Math.random() * ERROR_FACES.length);
return ERROR_FACES[randIndex];
}
export interface EmptyViewProps {
message: string;
messageExtra?: JSX.Element | string;
retry?: () => void;
noFaces?: boolean;
sx?: SxProps<Theme>;
}
const ExtraMessage = ({ messageExtra }: Pick<EmptyViewProps, 'messageExtra'>) => {
const { t } = useTranslation();
const [showFullError, setShowFullError] = useState(false);
const { isGraphqlException, graphqlError, graphqlStackTrace } = extractGraphqlExceptionInfo(messageExtra);
if (!isGraphqlException) {
return (
<Typography
variant="body1"
sx={{ wordBreak: 'break-word', whiteSpace: 'pre-line', pointerEvents: 'all' }}
color="textSecondary"
>
{messageExtra}
</Typography>
);
}
return (
<>
<Stack
sx={{
flexDirection: 'row',
flexWrap: 'wrap',
gap: 1,
justifyContent: 'center',
alignItems: 'center',
}}
>
<Typography
variant="body1"
sx={{ wordBreak: 'break-word', whiteSpace: 'pre-line', pointerEvents: 'all' }}
>
{graphqlError}
</Typography>
<Button variant="text" onClick={() => setShowFullError(!showFullError)} sx={{ pointerEvents: 'all' }}>
{t(showFullError ? 'global.button.show_less' : 'global.button.show_more')}
</Button>
</Stack>
<Collapse in={showFullError}>
<Typography
variant="body1"
color="textSecondary"
sx={{ wordBreak: 'break-word', whiteSpace: 'pre-line', pointerEvents: 'all' }}
>
{graphqlStackTrace}
</Typography>
</Collapse>
</>
);
};
export function EmptyView({ message, messageExtra, retry, noFaces, sx }: EmptyViewProps) {
const { t } = useTranslation();
const errorFace = useMemo(() => getRandomErrorFace(), []);
return (
<Stack
sx={{
p: 2,
textAlign: 'center',
alignItems: 'center',
justifyContent: 'center',
minWidth: '-webkit-fill-available',
maxWidth: '100%',
minHeight: '100%',
pointerEvents: 'none',
...sx,
}}
>
{!noFaces && (
<Typography variant="h3" gutterBottom sx={{ pointerEvents: 'all' }}>
{errorFace}
</Typography>
)}
<Typography variant="h5" sx={{ wordBreak: 'break-word', whiteSpace: 'pre-line', pointerEvents: 'all' }}>
{message}
</Typography>
<ExtraMessage messageExtra={messageExtra} />
{retry && (
<Button onClick={retry} sx={{ pointerEvents: 'all' }}>
{t('global.button.retry')}
</Button>
)}
</Stack>
);
}

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 { EmptyView, EmptyViewProps } from '@/base/components/feedback/EmptyView.tsx';
export function EmptyViewAbsoluteCentered({ sx, ...emptyViewProps }: EmptyViewProps) {
return (
<EmptyView
{...emptyViewProps}
sx={{
position: 'absolute',
minHeight: '-webkit-fill-available',
...sx,
}}
/>
);
}

View File

@@ -0,0 +1,91 @@
/*
* 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 { Component, ErrorInfo, ReactNode, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { t } from 'i18next';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { EmptyView } from '@/base/components/feedback/EmptyView.tsx';
interface Props {
children?: ReactNode;
setTrackPathChange: (change: boolean) => void;
}
interface State {
error: any;
}
class RealErrorBoundary extends Component<Props, State> {
// eslint-disable-next-line react/state-in-constructor
public state: State = { error: null };
private prevPath: string = '';
public static getDerivedStateFromError(error: any): State {
// Update state so the next render will show the fallback UI.
return { error };
}
componentDidMount() {
this.prevPath = window.location.pathname;
}
public componentDidUpdate() {
if (window.location.pathname !== this.prevPath) {
this.setState({ error: null });
}
this.prevPath = window.location.pathname;
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// eslint-disable-next-line
console.error('Uncaught error:', error, errorInfo);
// eslint-disable-next-line react/destructuring-assignment
this.props.setTrackPathChange(true);
}
public render() {
const { error } = this.state;
if (error) {
return (
<EmptyView
message={t('global.error.label.unrecoverable_error')}
messageExtra={getErrorMessage(error)}
retry={() => window.location.reload()}
/>
);
}
const { children } = this.props;
return children;
}
}
export const ErrorBoundary = ({ children }: { children: React.ReactNode }) => {
const [key, setKey] = useState(0);
const { pathname } = useLocation();
const previousPathnameRef = useRef(pathname);
const [trackPathChange, setTrackPathChange] = useState(false);
useEffect(() => {
if (trackPathChange && previousPathnameRef.current !== pathname) {
previousPathnameRef.current = pathname;
setKey((currentKey) => (currentKey + 1) % 999999);
setTrackPathChange(false);
}
}, [pathname, previousPathnameRef.current, trackPathChange]);
return (
<RealErrorBoundary key={key} setTrackPathChange={setTrackPathChange}>
{children}
</RealErrorBoundary>
);
};

View File

@@ -0,0 +1,53 @@
/*
* 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 React, { type JSX } from 'react';
import CircularProgress from '@mui/material/CircularProgress';
import Box from '@mui/material/Box';
interface IProps {
shouldRender?: boolean | (() => boolean);
children?: React.ReactNode;
component?: string | React.FunctionComponent<any> | React.ComponentClass<any, any>;
componentProps?: any;
usePadding?: boolean;
}
export function LoadingPlaceholder(props: IProps) {
const { children, shouldRender, component, componentProps, usePadding } = props;
let condition = true;
if (shouldRender !== undefined) {
condition = shouldRender instanceof Function ? shouldRender() : shouldRender;
}
if (condition) {
if (component) {
return React.createElement(component, componentProps);
}
if (children) {
return children as JSX.Element;
}
}
return (
<Box
sx={{
margin: '0px auto',
marginTop: usePadding ? 'unset' : '10px',
marginBottom: usePadding ? 'unset' : '10px',
padding: usePadding ? '10px 0' : 'unset',
display: 'flex',
justifyContent: 'center',
}}
>
<CircularProgress thickness={5} />
</Box>
);
}

View File

@@ -0,0 +1,34 @@
/*
* 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 Box from '@mui/material/Box';
import CircularProgress, { CircularProgressProps } from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography';
export const Progress = ({
progress,
showText = true,
progressProps = {},
}: {
progress: number;
showText?: boolean;
progressProps?: CircularProgressProps;
}) => (
<Box sx={{ display: 'grid', placeItems: 'center', position: 'relative' }}>
<CircularProgress {...progressProps} variant="determinate" value={progress} />
{showText && (
<Box sx={{ position: 'absolute' }}>
<Typography
sx={{
fontSize: '0.8rem',
}}
>{`${Math.round(progress)}%`}</Typography>
</Box>
)}
</Box>
);

View File

@@ -0,0 +1,117 @@
/*
* 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 { closeSnackbar, CustomContentProps, SnackbarContent, VariantType } from 'notistack';
import { ForwardedRef, forwardRef, Fragment, memo } from 'react';
import Alert from '@mui/material/Alert';
import AlertTitle from '@mui/material/AlertTitle';
import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@mui/material/styles';
import { awaitConfirmation } from '@/base/utils/AwaitableDialog.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
import { extractGraphqlExceptionInfo } from '@/lib/HelperFunctions.ts';
import { TranslationKey } from '@/base/Base.types.ts';
const MAX_DESCRIPTION_LENGTH = 200;
const SNACKBAR_VARIANT_TO_TRANSLATION_KEY: Record<VariantType, TranslationKey> = {
default: 'global.label.info',
info: 'global.label.info',
success: 'global.label.success',
warning: 'global.label.warning',
error: 'global.label.error',
};
export const SnackbarWithDescription = memo(
forwardRef(
(
{
id,
message,
description,
variant,
action,
}: CustomContentProps & {
// eslint-disable-next-line react/no-unused-prop-types
description?: string;
},
ref: ForwardedRef<HTMLDivElement>,
) => {
const { t } = useTranslation();
const theme = useTheme();
const severity = variant === 'default' ? 'info' : variant;
const finalAction = typeof action === 'function' ? action(id) : action;
const { isGraphqlException, graphqlError, graphqlStackTrace } = extractGraphqlExceptionInfo(description);
const finalDescription = isGraphqlException ? graphqlError : description;
const isDescriptionTooLong = (finalDescription?.length ?? 0) > MAX_DESCRIPTION_LENGTH;
const actualDescription = isDescriptionTooLong
? finalDescription?.slice(0, MAX_DESCRIPTION_LENGTH)
: finalDescription;
const TitleComponent = actualDescription?.length ? AlertTitle : Fragment;
return (
<SnackbarContent ref={ref}>
<Alert
elevation={1}
severity={severity}
action={finalAction}
sx={{
wordBreak: 'break-word',
minWidth: '300px',
[theme.breakpoints.down(MediaQuery.MOBILE_WIDTH)]: {
maxWidth: '100vw',
},
[theme.breakpoints.between(MediaQuery.MOBILE_WIDTH, MediaQuery.TABLET_WIDTH)]: {
maxWidth: '75vw',
},
[theme.breakpoints.up(MediaQuery.TABLET_WIDTH)]: {
maxWidth: '50vw',
},
}}
onClose={() => closeSnackbar(id)}
>
<TitleComponent>{message}</TitleComponent>
{actualDescription}
{isDescriptionTooLong || (isGraphqlException && graphqlStackTrace) ? (
<Button
onClick={() => {
awaitConfirmation({
title:
typeof message === 'string'
? message
: t(SNACKBAR_VARIANT_TO_TRANSLATION_KEY[variant]),
message: description ?? '',
actions: {
cancel: { show: false },
confirm: { title: t('global.label.close') },
},
}).catch(
defaultPromiseErrorHandler(
`SnackbarWithDescription: ${id} - ${message} - ${description}`,
),
);
}}
size="small"
>
{t('global.button.show_more')}
</Button>
) : (
''
)}
</Alert>
</SnackbarContent>
);
},
),
);

View File

@@ -0,0 +1,29 @@
/*
* 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 Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { ComponentProps } from 'react';
import { ButtonSelect } from '@/base/components/buttons/ButtonSelect.tsx';
export const ButtonSelectInput = <Value extends string | number>({
label,
description,
...buttonSelectProps
}: ComponentProps<typeof ButtonSelect<Value>> & { label: string; description?: string }) => (
<Stack>
<Typography>{label}</Typography>
{description && (
<Typography variant="body2" color="textDisabled">
{description}
</Typography>
)}
<ButtonSelect {...buttonSelectProps} />
</Stack>
);

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 { styled } from '@mui/material/styles';
export const CheckboxContainer = styled('div')({
display: 'flex',
flexDirection: 'column',
maxHeight: '170px',
overflow: 'auto',
});

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/.
*/
import Checkbox, { CheckboxProps } from '@mui/material/Checkbox';
import FormControlLabel, { FormControlLabelProps } from '@mui/material/FormControlLabel';
import React from 'react';
interface IProps extends CheckboxProps {
label?: FormControlLabelProps['label'];
}
export const CheckboxInput: React.FC<IProps> = ({ label, sx, ...rest }) => (
<FormControlLabel control={<Checkbox {...rest} />} label={label} sx={sx} />
);

View File

@@ -0,0 +1,108 @@
/*
* 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 { useMemo, useState } from 'react';
import Button from '@mui/material/Button';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Dialog from '@mui/material/Dialog';
import Switch from '@mui/material/Switch';
import IconButton from '@mui/material/IconButton';
import FilterListIcon from '@mui/icons-material/FilterList';
import ListItemText from '@mui/material/ListItemText';
import ListItem from '@mui/material/ListItem';
import { useTranslation } from 'react-i18next';
import { Virtuoso } from 'react-virtuoso';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import { translateExtensionLanguage } from '@/features/extension/Extensions.utils.ts';
import { languageSortComparator, toUniqueLanguageCodes } from '@/base/utils/Languages.ts';
interface IProps {
selectedLanguages: string[];
setSelectedLanguages: (languages: string[]) => void;
languages: string[];
}
export function LanguageSelect(props: IProps) {
const { t } = useTranslation();
const { selectedLanguages, setSelectedLanguages, languages } = props;
const [tmpSelectedLanguages, setTmpSelectedLanguages] = useState(toUniqueLanguageCodes(selectedLanguages));
const [open, setOpen] = useState<boolean>(false);
const languagesSortedBySelectState = useMemo(
() =>
toUniqueLanguageCodes([
...tmpSelectedLanguages.toSorted(languageSortComparator),
...languages.toSorted(languageSortComparator),
]),
[languages, tmpSelectedLanguages],
);
const handleCancel = () => {
setOpen(false);
setTmpSelectedLanguages(toUniqueLanguageCodes(selectedLanguages));
};
const handleOk = () => {
setOpen(false);
setSelectedLanguages(toUniqueLanguageCodes(tmpSelectedLanguages));
};
const handleChange = (language: string, selected: boolean) => {
if (selected) {
setTmpSelectedLanguages([...tmpSelectedLanguages, language]);
} else {
setTmpSelectedLanguages(tmpSelectedLanguages.toSpliced(tmpSelectedLanguages.indexOf(language), 1));
}
};
return (
<>
<CustomTooltip title={t('settings.title')}>
<IconButton onClick={() => setOpen(true)} aria-label="display more actions" edge="end" color="inherit">
<FilterListIcon />
</IconButton>
</CustomTooltip>
<Dialog fullWidth maxWidth="xs" open={open} onClose={handleCancel}>
<DialogTitle>{t('global.language.title.enabled_languages')}</DialogTitle>
<DialogContent dividers sx={{ padding: 0 }}>
<Virtuoso
style={{
height: languagesSortedBySelectState.length * 54,
minHeight: '25vh',
maxHeight: '50vh',
}}
data={languagesSortedBySelectState}
increaseViewportBy={400}
computeItemKey={(index) => languagesSortedBySelectState[index]}
itemContent={(_index, language) => (
<ListItem>
<ListItemText primary={translateExtensionLanguage(language)} />
<Switch
checked={tmpSelectedLanguages.includes(language)}
onChange={(e) => handleChange(language, e.target.checked)}
/>
</ListItem>
)}
/>
</DialogContent>
<DialogActions>
<Button autoFocus onClick={handleCancel} color="primary">
{t('global.button.cancel')}
</Button>
<Button onClick={handleOk} color="primary">
{t('global.button.ok')}
</Button>
</DialogActions>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,47 @@
/*
* 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 InputAdornment from '@mui/material/InputAdornment';
import TextField, { TextFieldProps } from '@mui/material/TextField';
import { useState } from 'react';
import IconButton from '@mui/material/IconButton';
import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff';
import { useTranslation } from 'react-i18next';
export const PasswordTextField = (props: TextFieldProps) => {
const { t } = useTranslation();
const [showPassword, setShowPassword] = useState(false);
const handleClickShowPassword = () => setShowPassword((show) => !show);
return (
<TextField
id="password"
name="password"
label={t('global.label.password')}
type={showPassword ? 'text' : 'password'}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="start">
<IconButton
aria-label="toggle password visibility"
onClick={handleClickShowPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
},
}}
{...props}
/>
);
};

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/.
*/
import FormControlLabel from '@mui/material/FormControlLabel';
import Radio, { RadioProps } from '@mui/material/Radio';
import React from 'react';
export interface RadioInputProps extends RadioProps {
label?: string;
}
export const RadioInput: React.FC<RadioInputProps> = ({ label, sx, ...rest }) => (
<FormControlLabel control={<Radio {...rest} />} label={label} sx={sx} />
);

View File

@@ -0,0 +1,37 @@
/*
* 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 TextField, { TextFieldProps } from '@mui/material/TextField';
import IconButton, { IconButtonProps } from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import CancelIcon from '@mui/icons-material/Cancel';
export const SearchTextField = ({
onCancel,
cancelButtonProps,
...textFieldProps
}: TextFieldProps & { onCancel: () => void; cancelButtonProps?: IconButtonProps }) => (
<TextField
{...textFieldProps}
slotProps={{
input: {
...textFieldProps.InputProps,
sx: {
color: 'inherit',
},
endAdornment: textFieldProps.InputProps?.endAdornment ?? (
<InputAdornment position="end">
<IconButton {...cancelButtonProps} onClick={() => onCancel()}>
<CancelIcon />
</IconButton>
</InputAdornment>
),
},
}}
/>
);

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/.
*/
import MuiSelect from '@mui/material/Select';
export const Select = <Value,>({
children,
maxSelectionHeightPx = 250,
...props
}: React.ComponentProps<typeof MuiSelect<Value>> & { maxSelectionHeightPx?: number }) => (
<MuiSelect<Value> MenuProps={{ PaperProps: { style: { maxHeight: maxSelectionHeightPx } } }} {...props}>
{children}
</MuiSelect>
);

View File

@@ -0,0 +1,41 @@
/*
* 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 Slider, { SliderProps } from '@mui/material/Slider';
import Typography, { TypographyProps } from '@mui/material/Typography';
import Stack from '@mui/material/Stack';
import { ResetButton } from '@/base/components/buttons/ResetButton.tsx';
export const SliderInput = ({
label,
value,
onDefault,
slotProps,
}: {
label: string;
value: number | string;
onDefault?: () => void;
slotProps?: {
label?: TypographyProps;
value?: TypographyProps;
slider?: SliderProps;
};
}) => (
<Stack sx={{ flexDirection: 'row', gap: 2, alignItems: 'center' }}>
<Stack sx={{ flexBasis: '25%' }}>
<Typography {...slotProps?.label} sx={{ ...slotProps?.label?.sx }}>
{label}
</Typography>
<Typography {...slotProps?.value} sx={{ ...slotProps?.value?.sx }}>
{value}
</Typography>
</Stack>
<Slider {...slotProps?.slider} sx={{ flexBasis: '75%', ...slotProps?.slider?.sx }} />
{onDefault && <ResetButton asIconButton onClick={onDefault} />}
</Stack>
);

View File

@@ -0,0 +1,23 @@
/*
* 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 ArrowDownward from '@mui/icons-material/ArrowDownward';
import ArrowUpward from '@mui/icons-material/ArrowUpward';
import { memo } from 'react';
import { RadioInput, RadioInputProps } from '@/base/components/inputs/RadioInput.tsx';
interface IProps extends RadioInputProps {
sortDescending?: boolean | null | undefined;
}
export const SortRadioInput = memo(({ sortDescending, ...rest }: IProps) => (
<RadioInput
checkedIcon={sortDescending ? <ArrowDownward color="primary" /> : <ArrowUpward color="primary" />}
{...rest}
/>
));

View File

@@ -0,0 +1,48 @@
/*
* 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 DisabledByDefaultRounded from '@mui/icons-material/DisabledByDefaultRounded';
import Checkbox, { CheckboxProps } from '@mui/material/Checkbox';
import React, { useCallback } from 'react';
type CheckState = boolean | undefined | null;
function nextState(state: CheckState): CheckState {
if (state === true) return false;
if (state === false) return undefined;
return true;
}
export interface ThreeStateCheckboxProps extends Omit<CheckboxProps, 'checked' | 'onChange'> {
checked?: boolean | undefined | null;
onChange?: (checked: boolean | undefined | null) => void;
}
/**
* When checked is true, checkbox contains checkmark
* When checked is false, checkbox contains cross
* When checked is null or undefined, checkbox is empty
*/
export const ThreeStateCheckbox: React.FC<ThreeStateCheckboxProps> = ({ checked, onChange, ...rest }) => {
const handleChange = useCallback(() => {
if (onChange) {
const newState = nextState(checked);
onChange(newState);
}
}, [onChange]);
return (
<Checkbox
indeterminateIcon={<DisabledByDefaultRounded />}
checked={checked === true}
indeterminate={checked === false}
onChange={handleChange}
{...rest}
/>
);
};

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/.
*/
import FormControlLabel from '@mui/material/FormControlLabel';
import React from 'react';
import { ThreeStateCheckbox, ThreeStateCheckboxProps } from '@/base/components/inputs/ThreeStateCheckbox.tsx';
interface IProps extends ThreeStateCheckboxProps {
label?: string;
}
export const ThreeStateCheckboxInput: React.FC<IProps> = ({ label, sx, ...rest }) => (
<FormControlLabel control={<ThreeStateCheckbox {...rest} />} label={label} sx={sx} />
);

View File

@@ -0,0 +1,14 @@
/*
* 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 { Link } from 'react-router-dom';
import ListItemButton, { ListItemButtonProps } from '@mui/material/ListItemButton';
export function ListItemLink(props: ListItemButtonProps<typeof Link>) {
return <ListItemButton component={Link} {...props} />;
}

View File

@@ -0,0 +1,41 @@
/*
* 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 Avatar, { AvatarProps } from '@mui/material/Avatar';
import { SpinnerImage, SpinnerImageProps } from '@/base/components/SpinnerImage.tsx';
export const ListCardAvatar = ({
iconUrl,
alt,
slots,
}: {
iconUrl: string;
alt: string;
slots?: { avatarProps?: Partial<AvatarProps>; spinnerImageProps?: Partial<SpinnerImageProps> };
}) => (
<Avatar
variant="rounded"
alt={alt}
{...slots?.avatarProps}
sx={{
width: 56,
height: 56,
flex: '0 0 auto',
background: 'transparent',
...slots?.avatarProps?.sx,
}}
>
<SpinnerImage
alt={alt}
src={iconUrl}
{...slots?.spinnerImageProps}
spinnerStyle={{ small: true, ...slots?.spinnerImageProps?.spinnerStyle }}
imgStyle={{ objectFit: 'cover', width: '100%', height: '100%', ...slots?.spinnerImageProps?.imgStyle }}
/>
</Avatar>
);

View File

@@ -0,0 +1,27 @@
/*
* 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 CardContent, { CardContentProps } from '@mui/material/CardContent';
export const ListCardContent = ({ children, ...props }: CardContentProps) => (
<CardContent
{...props}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 1,
'&:last-child': {
paddingBottom: 1,
},
...props.sx,
}}
>
{children}
</CardContent>
);

View File

@@ -0,0 +1,27 @@
/*
* 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 { Link } from 'react-router-dom';
import CardActionArea from '@mui/material/CardActionArea';
import { ComponentProps } from 'react';
export const OptionalCardActionAreaLink = ({
disabled,
children,
...props
}: ComponentProps<typeof CardActionArea> & ComponentProps<typeof Link> & { disabled?: boolean }) => {
if (disabled) {
return children;
}
return (
<CardActionArea component={Link} {...props}>
{children}
</CardActionArea>
);
};

View File

@@ -0,0 +1,51 @@
/*
* 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/.
*/
/*
* src: https://github.com/webzep/mui-nested-menu/blob/main/packages/mui-nested-menu/src/components/IconMenuItem.tsx (2024-04-20 01:42)
*/
import ListItemIcon from '@mui/material/ListItemIcon';
import MenuItem, { MenuItemProps as MuiMenuItemProps } from '@mui/material/MenuItem';
import { SxProps, Theme } from '@mui/material/styles';
import React, { forwardRef, RefObject } from 'react';
import { OverridableComponent } from '@mui/material/OverridableComponent';
import { SvgIconTypeMap } from '@mui/material/SvgIcon';
import ListItemText from '@mui/material/ListItemText';
type IconMenuItemProps = {
MenuItemProps?: MuiMenuItemProps;
className?: string;
disabled?: boolean;
label?: string;
renderLabel?: () => React.ReactNode;
LeftIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
ref?: RefObject<HTMLLIElement | null>;
RightIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
sx?: SxProps<Theme>;
};
export const IconMenuItem = forwardRef<HTMLLIElement, IconMenuItemProps>(
({ MenuItemProps, className, label, LeftIcon, renderLabel, RightIcon, ...props }, ref) => (
<MenuItem {...MenuItemProps} ref={ref} className={className} {...props}>
{LeftIcon && (
<ListItemIcon>
<LeftIcon fontSize="small" />
</ListItemIcon>
)}
<ListItemText>{label}</ListItemText>
{RightIcon && (
<ListItemIcon style={{ minWidth: 0 }}>
<RightIcon fontSize="small" />
</ListItemIcon>
)}
</MenuItem>
),
);

View File

@@ -0,0 +1,35 @@
/*
* 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 MuiMenu, { MenuProps } from '@mui/material/Menu';
import { useState, type JSX } from 'react';
export const Menu = ({
children,
onClose,
...props
}: Omit<MenuProps, 'children' | 'onClose'> &
Required<Pick<MenuProps, 'onClose'>> & {
children: (onClose: () => void, setHideMenu: (hide: boolean) => void) => JSX.Element | JSX.Element[];
}) => {
const [shouldHideMenu, setShouldHideMenu] = useState(false);
return (
<MuiMenu
{...props}
open={props.open}
onClose={onClose}
sx={{ visibility: !props.open || shouldHideMenu ? 'hidden' : 'visible' }}
>
{children(() => {
onClose({}, 'backdropClick');
setShouldHideMenu(false);
}, setShouldHideMenu)}
</MuiMenu>
);
};

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 { t as translate } from 'i18next';
import { TranslationKey } from '@/base/Base.types.ts';
export const createGetMenuItemTitle =
<Action extends string>(
isSingleMode: boolean,
actionToTranslationKey: Record<
Action,
{
action: {
single: TranslationKey;
selected: TranslationKey;
};
success: TranslationKey;
error: TranslationKey;
}
>,
) =>
(action: Action, count: number): string => {
const countSuffix = count > 0 ? ` (${count})` : '';
return `${translate(
actionToTranslationKey[action].action[isSingleMode ? 'single' : 'selected'],
)}${countSuffix}`;
};
export const createShouldShowMenuItem =
(isSingleMode: boolean) =>
(shouldBeVisible: boolean = false): boolean =>
isSingleMode ? shouldBeVisible : true;
export const createIsMenuItemDisabled =
(isSingleMode: boolean) =>
(shouldBeDisabled: boolean): boolean =>
isSingleMode ? false : shouldBeDisabled;

View File

@@ -0,0 +1,27 @@
/*
* 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 ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import MuiMenuItem, { MenuItemProps } from '@mui/material/MenuItem';
import { OverridableComponent } from '@mui/material/OverridableComponent';
import { SvgIconTypeMap } from '@mui/material/SvgIcon';
interface IProps extends MenuItemProps {
title: string;
Icon: OverridableComponent<SvgIconTypeMap> & { muiName: string };
}
export const MenuItem = ({ title, Icon, ...menuItemProps }: IProps) => (
<MuiMenuItem {...menuItemProps}>
<ListItemIcon>
<Icon fontSize="small" />
</ListItemIcon>
<ListItemText>{title}</ListItemText>
</MuiMenuItem>
);

View File

@@ -0,0 +1,235 @@
/*
* 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/.
*/
/*
* src: https://github.com/webzep/mui-nested-menu/blob/main/packages/mui-nested-menu/src/components/NestedMenuItem.tsx (2024-04-20 01:42)
*
* with a few changes to fix a bug on mobile devices where opening the sub menu immediately triggered the on click of the underlying menu item
*/
import Menu, { MenuProps as MuiMenuProps } from '@mui/material/Menu';
import { MenuItemProps as MuiMenuItemProps } from '@mui/material/MenuItem';
import {
ElementType,
forwardRef,
HTMLAttributes,
KeyboardEvent,
FocusEvent,
MouseEvent,
ReactNode,
RefAttributes,
useRef,
useState,
} from 'react';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import Box from '@mui/material/Box';
import { OverridableComponent } from '@mui/material/OverridableComponent';
import { SvgIconTypeMap } from '@mui/material/SvgIcon';
import { useMergedRef } from '@mantine/hooks';
import { IconMenuItem } from '@/base/components/menu/IconMenuItem.tsx';
import { getOptionForDirection } from '@/features/theme/services/ThemeCreator.ts';
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
export type NestedMenuItemProps = Omit<MuiMenuItemProps, 'button'> & {
parentMenuOpen: boolean;
component?: ElementType;
label?: string;
renderLabel?: () => ReactNode;
RightIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
LeftIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
children?: ReactNode;
className?: string;
tabIndex?: number;
disabled?: boolean;
ContainerProps?: HTMLAttributes<HTMLElement> & RefAttributes<HTMLElement>;
MenuProps?: Partial<Omit<MuiMenuProps, 'children'>>;
button?: true | undefined;
};
const NestedMenuItem = forwardRef<HTMLLIElement | null, NestedMenuItemProps>((props, ref) => {
const {
parentMenuOpen,
label,
renderLabel,
RightIcon = getOptionForDirection(ChevronRightIcon, ChevronLeftIcon),
LeftIcon,
children,
className,
tabIndex: tabIndexProp,
ContainerProps: ContainerPropsProp = {},
MenuProps,
...MenuItemProps
} = props;
const isTouchDevice = MediaQuery.useIsTouchDevice();
const { ref: containerRefProp, ...ContainerProps } = ContainerPropsProp;
const menuItemRef = useRef<HTMLLIElement | null>(null);
const mergedMenuItemRef = useMergedRef(ref, menuItemRef);
const containerRef = useRef<HTMLElement>(null);
const mergedContainerRef = useMergedRef(containerRefProp, containerRef);
const menuContainerRef = useRef<HTMLDivElement | null>(null);
const [isSubMenuOpen, setIsSubMenuOpen] = useState(false);
const changeMenuOpenState = (open: boolean) => {
if (isSubMenuOpen === open) {
return;
}
if (props.disabled) {
setIsSubMenuOpen(false);
return;
}
setIsSubMenuOpen(open);
};
const handleMouseEnter = (e: MouseEvent<HTMLElement>) => {
if (isTouchDevice) {
return;
}
changeMenuOpenState(true);
if (ContainerProps.onMouseEnter) {
ContainerProps.onMouseEnter(e);
}
};
const handleMouseLeave = (e: MouseEvent<HTMLElement>) => {
changeMenuOpenState(false);
if (ContainerProps.onMouseLeave) {
ContainerProps.onMouseLeave(e);
}
};
// Check if any immediate children are active
const isSubmenuFocused = () => {
const active = containerRef.current?.ownerDocument.activeElement ?? null;
if (menuContainerRef.current == null) {
return false;
}
for (const child of menuContainerRef.current.children) {
if (child === active) {
return true;
}
}
return false;
};
const handleFocus = (e: FocusEvent<HTMLElement>) => {
if (isTouchDevice) {
return;
}
if (e.target === containerRef.current) {
changeMenuOpenState(true);
}
if (ContainerProps.onFocus) {
ContainerProps.onFocus(e);
}
};
const handleClick = (e: MouseEvent<HTMLElement>) => {
changeMenuOpenState(!isSubMenuOpen);
if (ContainerProps.onClick) {
ContainerProps.onClick(e);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
return;
}
if (isSubmenuFocused()) {
e.stopPropagation();
}
const active = containerRef.current?.ownerDocument.activeElement;
if (e.key === 'ArrowLeft' && isSubmenuFocused()) {
containerRef.current?.focus();
}
if (e.key === 'ArrowRight' && e.target === containerRef.current && e.target === active) {
const firstChild = menuContainerRef.current?.children[0] as HTMLDivElement;
firstChild?.focus();
}
};
const open = isSubMenuOpen && parentMenuOpen;
// Root element must have a `tabIndex` attribute for keyboard navigation
let tabIndex;
if (!props.disabled) {
tabIndex = tabIndexProp !== undefined ? tabIndexProp : -1;
}
return (
<Box
{...ContainerProps}
ref={mergedContainerRef}
onFocus={handleFocus}
onClick={handleClick}
tabIndex={tabIndex}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onKeyDown={handleKeyDown}
>
<IconMenuItem
MenuItemProps={MenuItemProps}
className={className}
ref={mergedMenuItemRef}
LeftIcon={LeftIcon}
RightIcon={RightIcon}
label={label}
renderLabel={renderLabel}
/>
<Menu
// Set pointer events to 'none' to prevent the invisible Popover div
// from capturing events for clicks and hovers
style={{ pointerEvents: 'none' }}
anchorEl={menuItemRef.current}
anchorOrigin={{
horizontal: getOptionForDirection('right', 'left'),
vertical: 'top',
}}
transformOrigin={{
horizontal: getOptionForDirection('left', 'right'),
vertical: 'top',
}}
open={open}
autoFocus={false}
disableAutoFocus
disableEnforceFocus
onClose={() => {
changeMenuOpenState(false);
}}
{...MenuProps}
>
<Box ref={menuContainerRef} style={{ pointerEvents: 'auto' }}>
{children}
</Box>
</Menu>
</Box>
);
});
NestedMenuItem.displayName = 'NestedMenuItem';
export { NestedMenuItem };

View File

@@ -0,0 +1,113 @@
/*
* 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 Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
type Action = {
show?: boolean;
title?: string;
contain?: boolean;
};
type Actions = {
extra?: Action;
cancel?: Action;
confirm?: Action;
};
export const ConfirmDialog = ({
title,
message,
actions: passedActions,
onExtra,
onCancel,
onConfirm,
}: {
title: string;
message: string;
actions?: Actions;
onExtra?: () => void;
onCancel: () => void;
onConfirm: () => void;
}) => {
const { t } = useTranslation();
const actions = {
extra: {
show: passedActions?.extra?.show ?? false,
title: passedActions?.extra?.title ?? '',
contain: passedActions?.extra?.contain ?? false,
},
cancel: {
show: passedActions?.cancel?.show ?? true,
title: passedActions?.cancel?.title ?? t('global.button.cancel'),
contain: passedActions?.cancel?.contain ?? false,
},
confirm: {
show: passedActions?.confirm?.show ?? true,
title: passedActions?.confirm?.title ?? t('global.button.ok'),
contain:
!passedActions?.extra?.contain &&
!passedActions?.cancel?.contain &&
!passedActions?.confirm?.contain &&
true,
},
} satisfies Actions;
return (
<Dialog open onClose={onCancel}>
<DialogTitle>{title}</DialogTitle>
<DialogContent
sx={{
whiteSpace: 'pre-line',
}}
>
{message}
</DialogContent>
<DialogActions>
<Stack
sx={{
flexDirection: 'row',
justifyContent: actions.extra.show ? 'space-between' : 'end',
width: '100%',
gap: 1,
}}
>
{actions.extra.show && (
<Button onClick={onExtra} variant={actions.extra.contain ? 'contained' : undefined}>
{actions.extra.title}
</Button>
)}
<Stack
sx={{
flexDirection: 'row',
gap: 1,
}}
>
{actions.cancel.show && (
<Button onClick={onCancel} variant={actions.cancel.contain ? 'contained' : undefined}>
{actions.cancel.title}
</Button>
)}
{actions.confirm.show && (
<Button onClick={onConfirm} variant={actions.confirm.contain ? 'contained' : undefined}>
{actions.confirm.title}
</Button>
)}
</Stack>
</Stack>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,36 @@
/*
* 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 Drawer from '@mui/material/Drawer';
import Box from '@mui/material/Box';
import React from 'react';
interface IProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
minHeight?: number;
}
export const OptionsPanel: React.FC<IProps> = ({ open, onClose, children, minHeight }) => (
<Drawer
anchor="bottom"
open={open}
onClose={onClose}
PaperProps={{
style: {
maxWidth: 600,
marginLeft: 'auto',
marginRight: 'auto',
minHeight,
},
}}
>
<Box>{children}</Box>
</Drawer>
);

View File

@@ -0,0 +1,55 @@
/*
* 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 Stack from '@mui/material/Stack';
import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import React, { useState } from 'react';
import { TabPanel } from '@/base/components/tabs/TabPanel.tsx';
import { OptionsPanel } from '@/base/components/modals/OptionsPanel.tsx';
interface IProps<T = string> {
open: boolean;
onClose: () => void;
tabs: T[];
tabTitle: (key: T) => React.ReactNode;
tabContent: (key: T) => React.ReactNode;
minHeight?: number;
}
export const OptionsTabs = <T extends string = string>({
open,
onClose,
tabs,
tabTitle,
tabContent,
minHeight,
}: IProps<T>) => {
const [tabNum, setTabNum] = useState(0);
return (
<OptionsPanel open={open} onClose={onClose} minHeight={minHeight}>
<Tabs
value={tabNum}
variant="fullWidth"
onChange={(e, newTab) => setTabNum(newTab)}
indicatorColor="primary"
textColor="primary"
>
{tabs.map((tab, tabIndex) => (
<Tab key={tab} value={tabIndex} label={tabTitle(tab)} />
))}
</Tabs>
{tabs.map((tab, tabIndex) => (
<TabPanel key={tab} index={tabIndex} currentIndex={tabNum}>
<Stack sx={{ px: 3, py: 1, minHeight }}>{tabContent(tab)}</Stack>
</TabPanel>
))}
</OptionsPanel>
);
};

View File

@@ -0,0 +1,125 @@
/*
* 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 Button from '@mui/material/Button';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Dialog from '@mui/material/Dialog';
import FormGroup from '@mui/material/FormGroup';
import { useTranslation } from 'react-i18next';
import Stack from '@mui/material/Stack';
import { useCallback, useMemo } from 'react';
import { CheckboxProps } from '@mui/material/Checkbox';
import { CheckboxInput } from '@/base/components/inputs/CheckboxInput.tsx';
import { useSelectableCollection } from '@/features/collection/hooks/useSelectableCollection.ts';
export function CheckboxListSetting<Item>({
title,
emptyMessage,
items,
getId,
getLabel,
isChecked,
open,
onClose,
slotProps,
}: {
title: string;
emptyMessage?: string;
items: Item[];
getId: (item: Item) => string;
getLabel: (item: Item) => string;
isChecked: (item: Item) => boolean;
open: boolean;
onClose: (selectedItems?: Item[]) => void;
slotProps?: {
checkbox?: Omit<CheckboxProps, 'checked' | 'onChange' | 'label'>;
};
}) {
const { t } = useTranslation();
const itemIds = useMemo(() => items.map(getId), [items]);
const currentSelectedItemIds = useMemo(() => items.filter(isChecked).map(getId), [items]);
const { selectedItemIds, handleSelection, handleSelectAll, reset } = useSelectableCollection(items.length, {
currentKey: 'default',
itemIds,
initialState: { default: currentSelectedItemIds },
});
const handleCancel = () => {
onClose();
reset();
};
const handleOk = useCallback(() => {
const didSelectionChange =
selectedItemIds.length !== currentSelectedItemIds.length ||
selectedItemIds.some((id) => !currentSelectedItemIds.includes(id));
const selectedItems = items.filter((item) => selectedItemIds.includes(getId(item)));
onClose(didSelectionChange ? selectedItems : undefined);
}, [selectedItemIds]);
return (
<Dialog
sx={{
'.MuiDialog-paper': {
maxHeight: 435,
width: '80%',
},
}}
maxWidth="xs"
open={open}
onClose={handleCancel}
>
<DialogTitle>{title}</DialogTitle>
<DialogContent dividers>
<FormGroup>
{items.length === 0 && <span>{emptyMessage}</span>}
{items.map((item) => (
<CheckboxInput
{...slotProps?.checkbox}
checked={selectedItemIds.includes(getId(item))}
onChange={(e, checked) => handleSelection(getId(item), checked)}
label={getLabel(item)}
key={getId(item)}
/>
))}
</FormGroup>
</DialogContent>
<DialogActions>
<Stack sx={{ width: '100%' }}>
<Stack
direction="row"
sx={{
justifyContent: 'space-between',
alignItems: 'end',
width: '100%',
}}
>
<Button onClick={() => handleSelectAll(!selectedItemIds.length, items.map(getId))}>
{t(selectedItemIds.length ? 'global.button.reset' : 'global.button.select_all')}
</Button>
<Stack direction="row">
<Button autoFocus onClick={handleCancel} color="primary">
{t('global.button.cancel')}
</Button>
{!!items.length && (
<Button onClick={handleOk} color="primary">
{t('global.button.ok')}
</Button>
)}
</Stack>
</Stack>
</Stack>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,153 @@
/*
* 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 Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import ListItemText from '@mui/material/ListItemText';
import Stack from '@mui/material/Stack';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import ListItemButton from '@mui/material/ListItemButton';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import dayjs from 'dayjs';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
export const DateSetting = ({
settingName,
value,
defaultValue,
handleChange,
remove,
}: {
settingName: string;
value?: string;
defaultValue?: string;
handleChange: (path?: string | null) => void;
remove?: boolean;
}) => {
const { t } = useTranslation();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [dialogValue, setDialogValue] = useState(value ?? defaultValue);
useEffect(() => {
if (!value) {
return;
}
setDialogValue(value);
}, [value]);
const closeDialog = useCallback(
(resetValue: boolean) => {
setIsDialogOpen(false);
if (resetValue) {
setDialogValue(value ?? defaultValue);
}
},
[value],
);
const closeDialogWithReset = useCallback(() => closeDialog(true), [closeDialog]);
const updateSetting = useCallback(
(newValue?: string, shouldCloseDialog: boolean = true) => {
if (shouldCloseDialog) {
closeDialog(false);
}
const didValueChange = value !== newValue;
if (!didValueChange) {
return;
}
handleChange(newValue);
},
[value, handleChange, closeDialog],
);
return (
<>
<ListItemButton onClick={() => setIsDialogOpen(true)}>
<ListItemText
primary={settingName}
secondary={value ? dayjs(Number(value)).format('L') : '-'}
secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }}
/>
</ListItemButton>
<Dialog open={isDialogOpen} onClose={closeDialog}>
<DialogTitle>{settingName}</DialogTitle>
<DialogContent>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={dayjs.locale()}>
<DatePicker
value={dialogValue ? dayjs(Number(dialogValue)) : null}
onChange={(date) => {
if (!date) return;
setDialogValue(date.valueOf().toString());
}}
/>
</LocalizationProvider>
</DialogContent>
<DialogActions>
<Stack
direction="row"
sx={{
justifyContent: 'space-between',
alignItems: 'end',
width: '100%',
}}
>
<Stack>
{defaultValue !== undefined && (
<Button
onClick={() => {
setDialogValue(defaultValue);
updateSetting(defaultValue, false);
}}
color="primary"
>
{t('global.button.reset_to_default')}
</Button>
)}
{remove && (
<Button
onClick={() => {
setDialogValue(undefined);
updateSetting(undefined, false);
}}
color="primary"
>
{t('global.button.remove')}
</Button>
)}
</Stack>
<Stack direction="row">
<Button onClick={closeDialogWithReset} color="primary">
{t('global.button.cancel')}
</Button>
<Button
onClick={() => {
updateSetting(dialogValue);
}}
color="primary"
>
{t('global.button.ok')}
</Button>
</Stack>
</Stack>
</DialogActions>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,245 @@
/*
* 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 Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useEffect, useState, type JSX } from 'react';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import { useTranslation } from 'react-i18next';
import List from '@mui/material/List';
import DeleteIcon from '@mui/icons-material/Delete';
import IconButton from '@mui/material/IconButton';
import DialogContentText from '@mui/material/DialogContentText';
import InfoIcon from '@mui/icons-material/Info';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import { TextSetting, TextSettingProps } from '@/base/components/settings/text/TextSetting.tsx';
import { TextSettingDialog } from '@/base/components/settings/text/TextSettingDialog.tsx';
import { makeToast } from '@/base/utils/Toast.ts';
const MutableListItem = ({
handleDelete,
mutable = true,
deletable = true,
...textSettingProps
}: Omit<TextSettingProps, 'isPassword' | 'disabled'> & {
handleDelete: () => void;
mutable?: boolean;
deletable?: boolean;
}) => {
const { t } = useTranslation();
return (
<Stack sx={{ flexDirection: 'row', alignItems: 'center' }}>
{mutable ? (
<TextSetting {...textSettingProps} dialogTitle="" />
) : (
<ListItem>
<ListItemText secondary={textSettingProps.value} />
</ListItem>
)}
<CustomTooltip title={t('chapter.action.download.delete.label.action')} disabled={!deletable}>
<IconButton disabled={!deletable} onClick={handleDelete}>
<DeleteIcon />
</IconButton>
</CustomTooltip>
</Stack>
);
};
type MutableListSettingProps = Pick<TextSettingProps, 'settingName' | 'placeholder'> & {
valueInfos?: (
| [value: string]
| [value: string, Pick<React.ComponentProps<typeof MutableListItem>, 'mutable' | 'deletable'>]
)[];
description?: string;
dialogDisclaimer?: JSX.Element | string;
addItemButtonTitle?: string;
handleChange: (values: string[], removedValues: string[]) => void;
allowDuplicates?: boolean;
validateItem?: (value: string, tmpValues?: string[]) => boolean;
invalidItemError?: string;
};
const getValues = (valueInfos: MutableListSettingProps['valueInfos']): string[] =>
valueInfos?.map((valueInfo) => valueInfo[0]) ?? [];
export const MutableListSetting = ({
settingName,
description,
dialogDisclaimer,
valueInfos,
handleChange,
addItemButtonTitle,
placeholder,
allowDuplicates = false,
validateItem = () => true,
invalidItemError,
}: MutableListSettingProps) => {
const { t } = useTranslation();
const values = getValues(valueInfos);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [dialogValues, setDialogValues] = useState(values);
const [isAddItemDialogOpen, setIsAddItemDialogOpen] = useState(false);
useEffect(() => {
if (!valueInfos) {
return;
}
setDialogValues(values);
}, [valueInfos]);
const closeDialog = (resetValue: boolean = true) => {
if (resetValue) {
setDialogValues(values);
}
setIsDialogOpen(false);
};
const updateSetting = (index: number, newValue: string | undefined) => {
const deleteValue = newValue === undefined;
if (deleteValue) {
setDialogValues(dialogValues.toSpliced(index, 1));
return;
}
const isDuplicate = !allowDuplicates && dialogValues.includes(newValue);
if (isDuplicate) {
return;
}
if (newValue === '') {
return;
}
if (!validateItem?.(newValue, dialogValues)) {
makeToast(invalidItemError ?? t('global.error.label.invalid_input'), 'error');
return;
}
setDialogValues(dialogValues.toSpliced(index, 1, newValue.trim()));
};
const saveChanges = () => {
closeDialog(true);
const updatedValues = dialogValues.filter((dialogValue) => dialogValue !== '');
const removedValues = values.filter((value) => !updatedValues.includes(value));
handleChange(updatedValues, removedValues);
};
return (
<>
<ListItemButton onClick={() => setIsDialogOpen(true)}>
<ListItemText
primary={settingName}
secondary={values?.length ? values?.join(', ') : description}
secondaryTypographyProps={{
style: { display: 'flex', flexDirection: 'column', wordBreak: 'break-word' },
}}
/>
</ListItemButton>
<Dialog open={isDialogOpen} onClose={() => closeDialog()} fullWidth>
<DialogTitle>{settingName}</DialogTitle>
{(!!description || !!dialogDisclaimer) && (
<DialogContent>
<DialogContentText sx={{ paddingBottom: '10px' }} component="div">
{description && (
<Typography
variant="body1"
sx={{
whiteSpace: 'pre-line',
}}
>
{description}
</Typography>
)}
{dialogDisclaimer && (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<InfoIcon color="warning" />
<Typography
variant="body1"
sx={{
marginLeft: '10px',
marginTop: '5px',
whiteSpace: 'pre-line',
}}
>
{dialogDisclaimer}
</Typography>
</Stack>
)}
</DialogContentText>
</DialogContent>
)}
<DialogContent dividers sx={{ maxHeight: '300px' }}>
<List>
{dialogValues.map((dialogValue, index) => (
<MutableListItem
key={dialogValue}
settingName=""
placeholder={placeholder}
handleChange={(newValue: string) => updateSetting(index, newValue)}
handleDelete={() => updateSetting(index, undefined)}
value={dialogValue}
mutable={valueInfos?.find(([value]) => value === dialogValue)?.[1]?.mutable}
deletable={valueInfos?.find(([value]) => value === dialogValue)?.[1]?.deletable}
/>
))}
</List>
</DialogContent>
<DialogActions>
<Stack
direction="row"
sx={{
justifyContent: 'space-between',
width: '100%',
}}
>
<Button onClick={() => setIsAddItemDialogOpen(true)}>
{addItemButtonTitle ?? t('global.button.add')}
</Button>
<Stack direction="row">
<Button onClick={() => closeDialog()}>{t('global.button.cancel')}</Button>
<Button onClick={() => saveChanges()}>{t('global.button.ok')}</Button>
</Stack>
</Stack>
</DialogActions>
</Dialog>
{isAddItemDialogOpen && (
<TextSettingDialog
settingName=""
placeholder={placeholder}
handleChange={(newValue: string) => updateSetting(dialogValues.length, newValue)}
isDialogOpen={isAddItemDialogOpen}
setIsDialogOpen={setIsAddItemDialogOpen}
validate={(value) => validateItem?.(value, dialogValues) ?? true}
/>
)}
</>
);
};

View File

@@ -0,0 +1,217 @@
/*
* 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 Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import ListItemText from '@mui/material/ListItemText';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ListItemButton from '@mui/material/ListItemButton';
import * as React from 'react';
import ListItemIcon from '@mui/material/ListItemIcon';
import Slider from '@mui/material/Slider';
import DialogContentText from '@mui/material/DialogContentText';
import InfoIcon from '@mui/icons-material/Info';
import { SxProps, Theme } from '@mui/material/styles';
type BaseProps = {
settingTitle: string;
settingValue: string;
settingIcon?: React.ReactNode;
value: number;
defaultValue?: number;
minValue?: number;
maxValue?: number;
stepSize?: number;
dialogTitle?: string;
dialogDescription?: string;
dialogDisclaimer?: string;
valueUnit: string;
handleUpdate: (value: number) => void;
showSlider?: never;
disabled?: boolean;
listItemTextSx?: SxProps<Theme>;
handleLiveUpdate?: (value: number) => void;
};
type PropsWithSlider = Omit<BaseProps, 'defaultValue' | 'minValue' | 'maxValue' | 'showSlider'> &
Required<Pick<BaseProps, 'defaultValue' | 'minValue' | 'maxValue'>> & { showSlider: true };
type Props = BaseProps | PropsWithSlider;
export const NumberSetting = ({
settingTitle,
settingValue,
settingIcon,
dialogDescription,
dialogDisclaimer,
value,
defaultValue,
minValue,
maxValue,
stepSize,
dialogTitle = settingTitle,
valueUnit,
handleUpdate,
showSlider,
disabled = false,
handleLiveUpdate,
listItemTextSx: sx,
}: Props) => {
const { t } = useTranslation();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [dialogValue, setDialogValue] = useState(value);
const [originalValue, setOriginalValue] = useState(value);
const isInvalid =
(minValue !== undefined && minValue > dialogValue) || (maxValue !== undefined && maxValue < dialogValue);
const updateValue = useCallback(
(newValue: number, persist: boolean) => {
setDialogValue(newValue);
const didValueChange = newValue !== originalValue;
// Call handleUpdate if the value changed and 'persist' is true,
// otherwise call handleLiveUpdate if it's defined.
if (persist && didValueChange) {
handleUpdate(newValue);
} else if (handleLiveUpdate) {
handleLiveUpdate(newValue);
}
},
[originalValue, setDialogValue, handleLiveUpdate, handleUpdate],
);
const cancel = useCallback(() => {
updateValue(originalValue, true);
setOriginalValue(originalValue);
setIsDialogOpen(false);
}, [originalValue, handleUpdate]);
const resetToDefault = useCallback(() => {
if (defaultValue !== undefined) {
updateValue(defaultValue, true);
setOriginalValue(defaultValue);
setIsDialogOpen(false);
}
}, [defaultValue, handleUpdate]);
const submit = () => {
updateValue(dialogValue, true);
setOriginalValue(dialogValue);
setIsDialogOpen(false);
};
return (
<>
<ListItemButton disabled={disabled} onClick={() => setIsDialogOpen(true)}>
{settingIcon ? <ListItemIcon>{settingIcon}</ListItemIcon> : null}
<ListItemText
primary={settingTitle}
secondary={settingValue}
sx={sx}
secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }}
/>
</ListItemButton>
<Dialog open={isDialogOpen} onClose={cancel}>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent>
{(!!dialogDescription || !!dialogDisclaimer) && (
<DialogContentText sx={{ paddingBottom: '10px' }} component="div">
{dialogDescription && (
<Typography
variant="body1"
sx={{
whiteSpace: 'pre-line',
}}
>
{dialogDescription}
</Typography>
)}
{dialogDisclaimer && (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<InfoIcon color="warning" />
<Typography
variant="body1"
sx={{
marginLeft: '10px',
marginTop: '5px',
whiteSpace: 'pre-line',
}}
>
{dialogDisclaimer}
</Typography>
</Stack>
)}
</DialogContentText>
)}
<TextField
sx={{
width: '100%',
margin: 'auto',
}}
autoFocus
value={dialogValue}
type="number"
error={isInvalid}
helperText={isInvalid ? t('global.error.label.invalid_input') : ''}
onChange={(e) => {
const newValue = Number(e.target.value);
updateValue(newValue, false);
}}
slotProps={{
input: {
inputProps: { min: minValue, max: maxValue, step: stepSize },
endAdornment: <InputAdornment position="end">{valueUnit}</InputAdornment>,
},
}}
/>
{showSlider ? (
<Slider
aria-label="number-setting-slider"
defaultValue={defaultValue}
value={dialogValue}
step={stepSize}
min={minValue}
max={maxValue}
onChange={(_, newValue) => {
updateValue(newValue as number, false);
}}
/>
) : null}
</DialogContent>
<DialogActions>
{defaultValue !== undefined ? (
<Button onClick={resetToDefault} color="primary">
{t('global.button.reset_to_default')}
</Button>
) : null}
<Button onClick={cancel} color="primary">
{t('global.button.cancel')}
</Button>
<Button disabled={isInvalid} onClick={submit} color="primary">
{t('global.button.ok')}
</Button>
</DialogActions>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,155 @@
/*
* 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 Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import FormControl from '@mui/material/FormControl';
import ListItemText from '@mui/material/ListItemText';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import ListItemButton from '@mui/material/ListItemButton';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import DialogContentText from '@mui/material/DialogContentText';
import InfoIcon from '@mui/icons-material/Info';
import { Select } from '@/base/components/inputs/Select.tsx';
import { TranslationKey } from '@/base/Base.types.ts';
export type SelectSettingValueDisplayInfo = {
text: TranslationKey | string;
description?: TranslationKey | string;
disclaimer?: TranslationKey | string;
};
export type SelectSettingValue<Value> = [Value: Value, DisplayInfo: SelectSettingValueDisplayInfo];
export const SelectSetting = <SettingValue extends string | number>({
settingName,
dialogDescription,
value,
values,
handleChange,
disabled = false,
}: {
settingName: string;
dialogDescription?: string;
value: SettingValue;
values: SelectSettingValue<SettingValue>[];
handleChange: (value: SettingValue) => void;
disabled?: boolean;
}) => {
const { t } = useTranslation();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [dialogValue, setDialogValue] = useState(value);
const valueDisplayText = useMemo(() => values.find(([key]) => key === value)?.[1]?.text, [value]);
const dialogValueDisplayInfo = useMemo(() => values.find(([key]) => key === dialogValue)![1], [dialogValue]);
useEffect(() => {
if (!value) {
return;
}
setDialogValue(value);
}, [value]);
const closeDialog = (resetValue: boolean = true) => {
if (resetValue) {
setDialogValue(value);
}
setIsDialogOpen(false);
};
const updateSetting = () => {
closeDialog(false);
handleChange(dialogValue);
};
return (
<>
<ListItemButton disabled={disabled} onClick={() => setIsDialogOpen(true)}>
<ListItemText
primary={settingName}
secondary={valueDisplayText ? t(valueDisplayText as TranslationKey) : t('global.label.loading')}
secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }}
/>
</ListItemButton>
<Dialog open={isDialogOpen} onClose={() => closeDialog()} fullWidth>
<DialogTitle>{settingName}</DialogTitle>
<DialogContent>
{!!dialogDescription && (
<DialogContentText sx={{ paddingBottom: '10px' }}>{dialogDescription}</DialogContentText>
)}
{(!!dialogValueDisplayInfo.description || !!dialogValueDisplayInfo.disclaimer) && (
<DialogContentText sx={{ paddingBottom: '10px' }} component="div">
{dialogValueDisplayInfo.description && (
<Typography
variant="body1"
sx={{
whiteSpace: 'pre-line',
}}
>
{t(dialogValueDisplayInfo.description as TranslationKey)}
</Typography>
)}
{dialogValueDisplayInfo.disclaimer && (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<InfoIcon color="warning" />
<Typography
variant="body1"
sx={{
marginLeft: '10px',
marginTop: '5px',
whiteSpace: 'pre-line',
}}
>
{t(dialogValueDisplayInfo.disclaimer as TranslationKey)}
</Typography>
</Stack>
)}
</DialogContentText>
)}
<FormControl fullWidth>
<Select
id="dialog-select"
value={dialogValue}
onChange={(e) => setDialogValue(e.target.value as SettingValue)}
>
{values.map(([selectValue, { text: selectText }]) => (
<MenuItem key={selectValue} value={selectValue}>
{t(selectText as TranslationKey)}
</MenuItem>
))}
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => closeDialog()} color="primary">
{t('global.button.cancel')}
</Button>
<Button onClick={() => updateSetting()} color="primary">
{t('global.button.ok')}
</Button>
</DialogActions>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,126 @@
/*
* 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 Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import ListItemText from '@mui/material/ListItemText';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import ListItemButton from '@mui/material/ListItemButton';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { TimePicker } from '@mui/x-date-pickers/TimePicker';
import dayjs from 'dayjs';
export const TimeSetting = ({
settingName,
value,
defaultValue,
handleChange,
}: {
settingName: string;
value: string;
defaultValue: string;
handleChange: (path: string) => void;
}) => {
const { t } = useTranslation();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [dialogValue, setDialogValue] = useState(value);
useEffect(() => {
if (!value) {
return;
}
setDialogValue(value);
}, [value]);
const closeDialog = useCallback(
(resetValue: boolean) => {
setIsDialogOpen(false);
if (resetValue) {
setDialogValue(value);
}
},
[value],
);
const closeDialogWithReset = useCallback(() => closeDialog(true), [closeDialog]);
const updateSetting = useCallback(
(newValue: string, shouldCloseDialog: boolean = true) => {
if (shouldCloseDialog) {
closeDialog(false);
}
const didValueChange = value !== newValue;
if (!didValueChange) {
return;
}
handleChange(newValue);
},
[value, handleChange, closeDialog],
);
return (
<>
<ListItemButton onClick={() => setIsDialogOpen(true)}>
<ListItemText
primary={settingName}
secondary={dayjs(value, 'HH:mm').format('LT')}
secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }}
/>
</ListItemButton>
<Dialog open={isDialogOpen} onClose={closeDialog}>
<DialogTitle>{settingName}</DialogTitle>
<DialogContent>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={dayjs.locale()}>
<TimePicker
autoFocus
value={dayjs(dialogValue, 'HH:mm')}
defaultValue={dayjs(defaultValue, 'HH:mm')}
format="LT"
onChange={(time) => setDialogValue(time?.format('HH:mm') ?? '00:00')}
/>
</LocalizationProvider>
</DialogContent>
<DialogActions>
{defaultValue !== undefined ? (
<Button
onClick={() => {
setDialogValue(defaultValue);
updateSetting(defaultValue, false);
}}
color="primary"
>
{t('global.button.reset_to_default')}
</Button>
) : null}
<Button onClick={closeDialogWithReset} color="primary">
{t('global.button.cancel')}
</Button>
<Button
onClick={() => {
updateSetting(dialogValue);
}}
color="primary"
>
{t('global.button.ok')}
</Button>
</DialogActions>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,40 @@
/*
* 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 ListItemText from '@mui/material/ListItemText';
import ListItemButton from '@mui/material/ListItemButton';
import { useState } from 'react';
import { TextSettingDialog, TextSettingDialogProps } from '@/base/components/settings/text/TextSettingDialog.tsx';
export type TextSettingProps = Omit<TextSettingDialogProps, 'isDialogOpen' | 'setIsDialogOpen' | 'value'> &
Required<Pick<TextSettingDialogProps, 'value'>> & {
disabled?: boolean;
settingDescription?: string;
};
export const TextSetting = (props: TextSettingProps) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { settingName, settingDescription, value, isPassword = false, disabled = false } = props;
return (
<>
<ListItemButton disabled={disabled} onClick={() => setIsDialogOpen(true)}>
<ListItemText
primary={settingName}
secondary={settingDescription ?? (isPassword ? value.replace(/./g, '*') : value)}
secondaryTypographyProps={{
sx: { display: 'flex', flexDirection: 'column', wordWrap: 'break-word' },
}}
/>
</ListItemButton>
<TextSettingDialog {...props} isDialogOpen={isDialogOpen} setIsDialogOpen={setIsDialogOpen} />
</>
);
};

View File

@@ -0,0 +1,111 @@
/*
* 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 Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import TextField from '@mui/material/TextField';
import DialogActions from '@mui/material/DialogActions';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PasswordTextField } from '@/base/components/inputs/PasswordTextField.tsx';
export type TextSettingDialogProps = {
settingName: string;
dialogTitle?: string;
dialogDescription?: string;
value?: string;
handleChange: (value: string) => void;
isPassword?: boolean;
placeholder?: string;
isDialogOpen: boolean;
setIsDialogOpen: (open: boolean) => void;
validate?: (value: string) => boolean;
};
export const TextSettingDialog = ({
settingName,
dialogTitle = settingName,
dialogDescription,
value,
handleChange,
isPassword = false,
placeholder = '',
isDialogOpen,
setIsDialogOpen,
validate = () => true,
}: TextSettingDialogProps) => {
const { t } = useTranslation();
const [dialogValue, setDialogValue] = useState(value ?? '');
const [isValidValue, setIsValidValue] = useState(true);
const error = !isValidValue && !!dialogValue.length;
const TextFieldComponent = useMemo(() => (isPassword ? PasswordTextField : TextField), [isPassword]);
useEffect(() => {
if (!value) {
return;
}
setDialogValue(value);
}, [value]);
const closeDialog = (resetValue: boolean = true) => {
if (resetValue) {
setDialogValue(value ?? '');
setIsValidValue(true);
}
setIsDialogOpen(false);
};
const updateSetting = () => {
closeDialog(false);
handleChange(dialogValue);
};
return (
<Dialog open={isDialogOpen} onClose={() => closeDialog()} fullWidth>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent>
{!!dialogDescription && (
<DialogContentText sx={{ paddingBottom: '10px' }}>{dialogDescription}</DialogContentText>
)}
<TextFieldComponent
sx={{
width: '100%',
margin: 'auto',
}}
autoFocus
placeholder={placeholder}
value={dialogValue}
error={error}
helperText={error ? t('global.error.label.invalid_input') : ''}
onChange={(e) => {
const newValue = e.target.value;
setIsValidValue(validate(newValue));
setDialogValue(newValue);
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => closeDialog()} color="primary">
{t('global.button.cancel')}
</Button>
<Button onClick={() => updateSetting()} disabled={!isValidValue} color="primary">
{t('global.button.ok')}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,26 @@
/*
* 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 Box, { BoxProps } from '@mui/material/Box';
import React from 'react';
interface IProps extends BoxProps {
children: React.ReactNode;
index: any;
currentIndex: any;
}
export function TabPanel(props: IProps) {
const { children, index, currentIndex, ...boxProps } = props;
return (
<Box {...boxProps} role="tabpanel" hidden={index !== currentIndex} id={`simple-tabpanel-${index}`}>
{currentIndex === index && children}
</Box>
);
}

View File

@@ -0,0 +1,46 @@
/*
* 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 Tabs, { TabsProps } from '@mui/material/Tabs';
import { styled } from '@mui/material/styles';
import { ForwardedRef, forwardRef } from 'react';
import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
const StyledTabsMenu = styled(Tabs)(({ theme }) => ({
display: 'flex',
position: 'sticky',
left: 0,
right: 0,
zIndex: 1,
backgroundColor: theme.palette.background.default,
border: 0,
borderBottomWidth: 2,
borderStyle: 'solid',
borderColor: theme.palette.divider,
}));
export const TabsMenu = forwardRef(
({ children, sx, ...props }: TabsProps, ref: ForwardedRef<HTMLDivElement | null>) => {
const { appBarHeight } = useNavBarContext();
return (
<StyledTabsMenu
sx={{ ...sx, top: appBarHeight }}
ref={ref}
indicatorColor="primary"
textColor="primary"
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
{...props}
>
{children}
</StyledTabsMenu>
);
},
);

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/.
*/
import Box, { BoxProps } from '@mui/material/Box';
export const TabsWrapper = ({ children, ...props }: BoxProps) => (
<Box {...props} sx={{ ...props.sx, position: 'relative', height: `100%` }}>
{children}
</Box>
);

View File

@@ -0,0 +1,23 @@
/*
* 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 { styled } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
export const Kbd = styled(Typography)(({ theme }) => ({
display: 'inline-block',
padding: '0.2em 0.4em',
fontSize: '0.85em',
lineHeight: '1.4',
color: theme.palette.text.primary,
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
borderRadius: '3px',
boxShadow: `inset 0 -1px 0 ${theme.palette.divider}`,
fontFamily: 'monospace, monospace',
}));

View File

@@ -0,0 +1,41 @@
/*
* 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 Stack, { StackProps } from '@mui/material/Stack';
import Typography, { TypographyProps } from '@mui/material/Typography';
import { ReactNode } from 'react';
export const Metadata = ({
title,
value,
stackProps,
titleProps,
valueProps,
}: {
title: string;
value: ReactNode;
stackProps?: StackProps;
titleProps?: TypographyProps;
valueProps?: TypographyProps;
}) => (
<Stack
{...stackProps}
sx={{ flexDirection: 'row', columnGap: 1, flexWrap: 'wrap', alignItems: 'baseline', ...stackProps?.sx }}
>
<Typography
{...titleProps}
sx={{
color: 'text.secondary',
...titleProps?.sx,
}}
>
{title}
</Typography>
<Typography {...valueProps}>{value}</Typography>
</Stack>
);

View File

@@ -0,0 +1,35 @@
/*
* 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 Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Trans, useTranslation } from 'react-i18next';
import { TranslationKey } from '@/base/Base.types.ts';
/**
* Expects a translation key of format "{{value}}<0>superscript</0>"
* @param i18nKey
* @param value
* @constructor
*/
export const Superscript = ({ i18nKey, value }: { i18nKey: TranslationKey; value: string }) => {
const { t } = useTranslation();
return (
<Stack sx={{ flexDirection: 'row', gap: 0.25 }}>
<Trans
t={t}
// the type of "key" causes tsc error: "TS2590: Expression produces a union type that is too complex to represent"
i18nKey={i18nKey as any}
values={{ value }}
components={[<Typography variant="caption" sx={{ fontSize: 'x-small', opacity: 0.75 }} />]}
/>
</Stack>
);
};

View File

@@ -0,0 +1,41 @@
/*
* 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 { styled, Theme, TypographyVariant } from '@mui/material/styles';
import Typography, { TypographyProps } from '@mui/material/Typography';
import { shouldForwardProp } from '@/base/utils/ShouldForwardProp.ts';
const DEFAULT_LINE_HEIGHT = '1.5';
const getLineHeight = (theme: Theme, variant: TypographyProps['variant']): string => {
if (variant === undefined) {
return DEFAULT_LINE_HEIGHT;
}
if (!(variant in theme.typography)) {
return DEFAULT_LINE_HEIGHT;
}
return theme.typography[variant as TypographyVariant].lineHeight?.toString() ?? DEFAULT_LINE_HEIGHT;
};
type TypographyMaxLinesProps = {
lines?: number;
};
export const TypographyMaxLines = styled(Typography, {
shouldForwardProp: shouldForwardProp<TypographyMaxLinesProps>(['lines']),
})<TypographyProps & TypographyMaxLinesProps>(({ variant, theme, lines = 2 }) => ({
lineHeight: getLineHeight(theme, variant),
display: '-webkit-box',
WebkitLineClamp: `${lines}`,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
overflowWrap: 'break-word',
}));

View File

@@ -0,0 +1,28 @@
/*
* 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 { styled } from '@mui/material/styles';
import { TypographyProps } from '@mui/material/Typography';
import Stack from '@mui/material/Stack';
import { shouldForwardProp } from '@/base/utils/ShouldForwardProp.ts';
type StyledGroupHeaderProps = {
isFirstItem: boolean;
};
export const StyledGroupHeader = styled(Stack, {
shouldForwardProp: shouldForwardProp<StyledGroupHeaderProps>(['isFirstItem']),
})<StyledGroupHeaderProps & TypographyProps>(({ theme, isFirstItem }) => ({
paddingLeft: theme.spacing(3),
paddingTop: theme.spacing(0.75),
paddingBottom: theme.spacing(2),
fontWeight: 'bold',
backgroundColor: theme.palette.background.default,
[theme.breakpoints.down('sm')]: {
paddingTop: isFirstItem ? theme.spacing(1) : theme.spacing(0.75),
},
}));

View File

@@ -0,0 +1,17 @@
/*
* 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 Box from '@mui/material/Box';
import { styled } from '@mui/material/styles';
import { memo } from 'react';
export const StyledGroupItemWrapper = memo(
styled(Box)(({ theme }) => ({
padding: theme.spacing(0, 1, 1, 1),
})),
);

View File

@@ -0,0 +1,49 @@
/*
* 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 { ContextProp, TopItemListProps } from 'react-virtuoso';
import { ComponentProps, useMemo } from 'react';
import Box from '@mui/material/Box';
import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
import { GroupedVirtuosoPersisted } from '@/lib/virtuoso/Component/GroupedVirtuosoPersisted.tsx';
const StickyVirtuosoHeaderWithOffset =
(topOffset: number) =>
({ children, ...args }: TopItemListProps & ContextProp<unknown>) => (
<Box {...args} style={{ ...args.style, top: topOffset }}>
{children}
</Box>
);
export const StyledGroupedVirtuoso = ({
heightToSubtract = 0,
style,
...props
}: ComponentProps<typeof GroupedVirtuosoPersisted> & { heightToSubtract?: number }) => {
const { appBarHeight, bottomBarHeight } = useNavBarContext();
const TopItemList = useMemo(
() => StickyVirtuosoHeaderWithOffset(appBarHeight + heightToSubtract),
[appBarHeight, heightToSubtract],
);
return (
<GroupedVirtuosoPersisted
useWindowScroll
{...props}
components={{
TopItemList,
...props.components,
}}
style={{
...style,
height: `calc(100vh - ${heightToSubtract}px - ${appBarHeight}px - ${bottomBarHeight}px - ${!bottomBarHeight ? 'env(safe-area-inset-bottom)' : '0px'})`,
}}
/>
);
};

View File

@@ -0,0 +1,55 @@
/*
* 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 { StyledEngineProvider } from '@mui/material/styles';
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { QueryParamProvider } from 'use-query-params';
import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6';
import { SnackbarProvider } from 'notistack';
import { ActiveDeviceContextProvider } from '@/features/device/DeviceContext.tsx';
import { ReaderContextProvider } from '@/features/reader/contexts/ReaderContextProvider.tsx';
import { AppHotkeysProvider } from '@/features/hotkeys/AppHotkeysProvider.tsx';
import { SnackbarWithDescription } from '@/base/components/feedback/SnackbarWithDescription.tsx';
import { AppPageHistoryContextProvider } from '@/base/contexts/AppPageHistoryContext.tsx';
import { AppThemeContextProvider } from '@/features/theme/AppThemeContext.tsx';
import { NavBarContextProvider } from '@/features/navigation-bar/NavbarContext.tsx';
interface Props {
children: React.ReactNode;
}
export const AppContext: React.FC<Props> = ({ children }) => (
<Router>
<StyledEngineProvider injectFirst>
<AppThemeContextProvider>
<QueryParamProvider adapter={ReactRouter6Adapter}>
<NavBarContextProvider>
<AppPageHistoryContextProvider>
<ActiveDeviceContextProvider>
<SnackbarProvider
Components={{
default: SnackbarWithDescription,
info: SnackbarWithDescription,
success: SnackbarWithDescription,
warning: SnackbarWithDescription,
error: SnackbarWithDescription,
}}
>
<ReaderContextProvider>
<AppHotkeysProvider>{children}</AppHotkeysProvider>
</ReaderContextProvider>
</SnackbarProvider>
</ActiveDeviceContextProvider>
</AppPageHistoryContextProvider>
</NavBarContextProvider>
</QueryParamProvider>
</AppThemeContextProvider>
</StyledEngineProvider>
</Router>
);

View File

@@ -0,0 +1,20 @@
/*
* 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 { createContext, useContext } from 'react';
import { useHistory } from '@/base/hooks/useHistory.ts';
export const AppPageHistoryContext = createContext<string[]>([]);
export const useAppPageHistoryContext = () => useContext(AppPageHistoryContext);
export const AppPageHistoryContextProvider = ({ children }: { children: React.ReactNode }) => {
const history = useHistory();
return <AppPageHistoryContext.Provider value={history}>{children}</AppPageHistoryContext.Provider>;
};

View File

@@ -0,0 +1,39 @@
/*
* 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 { ComponentType, memo, forwardRef } from 'react';
type PropsSourceCreator<T, Props extends Record<string, any>> = (props: Props) => T;
export const withPropsFrom = <
ComponentProps extends Record<string, any>,
SourceProps extends Record<string, any>[],
SourcePropKeys extends keyof (ComponentProps | MergeObjectsArray<SourceProps>),
>(
Component: ComponentType<ComponentProps>,
propsSources: {
[K in keyof SourceProps]: PropsSourceCreator<SourceProps[K], Omit<ComponentProps, SourcePropKeys>>;
},
sourcePropKeys: SourcePropKeys[],
) =>
memo(
forwardRef<HTMLElement, Omit<ComponentProps, SourcePropKeys>>((props, ref) => {
const sourceProps = propsSources.reduce(
(acc, propsSource) => ({ ...acc, ...propsSource(props as Omit<ComponentProps, SourcePropKeys>) }),
{},
);
const selectedProps = Object.fromEntries(
Object.entries(sourceProps).filter(([key]) => sourcePropKeys.includes(key as SourcePropKeys)),
) as Pick<MergeObjectsArray<SourceProps>, SourcePropKeys>;
const combinedProps = { ...props, ...selectedProps } as unknown as ComponentProps;
return <Component {...combinedProps} ref={ref} />;
}),
);

View File

@@ -0,0 +1,203 @@
/*
* 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 { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ScrollDirection } from '@/base/Base.types.ts';
import { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
// "scrollBy" and "scrollTo" both require at least a change of 1px, otherwise, nothing happens
const MIN_SCROLL_AMOUNT_PX = 1;
const getScrollAmount = (
amountPerMs: number,
speedMs: number,
isRTL: boolean = false,
invert: boolean = false,
): number => {
const pxPerMs = Math.max(amountPerMs * speedMs, MIN_SCROLL_AMOUNT_PX);
const pxPerMsReadingMode = isRTL ? -pxPerMs : pxPerMs;
const pxPerMsInverted = invert ? pxPerMsReadingMode * -1 : pxPerMsReadingMode;
return pxPerMsInverted;
};
const getPxPerMs = (size: number, scrollAmountPercentage: number, scrollSpeedMs: number): number =>
(size * (scrollAmountPercentage / 100)) / scrollSpeedMs;
function handleScrolling(
smooth: boolean,
scrollSpeedMs: number,
setScrollTriggerId: (id: NodeJS.Timeout | number, type: 'interval' | 'animationFrame') => void,
performScroll: (elapsedTime: number) => void,
clearScrollTriggers: () => void,
): void {
clearScrollTriggers();
if (!smooth) {
setScrollTriggerId(
setInterval(() => {
performScroll(scrollSpeedMs);
}, scrollSpeedMs),
'interval',
);
return;
}
let startTime: number;
const triggerScroll = (timestamp: DOMHighResTimeStamp) => {
const elapsedTime = timestamp - (startTime ?? timestamp);
startTime = timestamp;
performScroll(elapsedTime);
setScrollTriggerId(requestAnimationFrame(triggerScroll), 'animationFrame');
};
setScrollTriggerId(requestAnimationFrame(triggerScroll), 'animationFrame');
}
export const useAutomaticScrolling = (
refOrCallback: MutableRefObject<HTMLElement | null> | (() => void) | undefined,
scrollPerSecond: number,
scrollDirection: Exclude<ScrollDirection, ScrollDirection.XY> = ScrollDirection.Y,
scrollAmountPercentage: number = 100,
invert: boolean = false,
smooth: boolean = false,
): {
isActive: boolean;
isPaused: boolean;
start: () => void;
cancel: () => void;
toggleActive: () => void;
pause: () => void;
resume: () => void;
} => {
const isCallback = typeof refOrCallback === 'function';
const elementStyle = useRef<CSSStyleDeclaration>(undefined);
const scrollTriggerTimer = useRef<NodeJS.Timeout>(undefined);
const scrollTriggerAnimationFrameId = useRef(-1);
const [isActive, setIsActive] = useState(false);
const [isPaused, setIsPaused] = useState(false);
// scroll amount is based on the screen dimensions, thus, the hook needs to update in case they change
const [screenDimensions, setScreenDimensions] = useState({ width: window.innerWidth, height: window.innerHeight });
useResizeObserver(
document.documentElement,
useCallback(() => {
const width = window.innerWidth;
const height = window.innerHeight;
if (screenDimensions.width !== width || screenDimensions.height !== height) {
setScreenDimensions({ width, height });
}
}, []),
);
const clearScrollTriggers = useCallback(() => {
clearTimeout(scrollTriggerTimer.current);
cancelAnimationFrame(scrollTriggerAnimationFrameId.current);
}, []);
const start = useCallback(() => {
setIsActive(true);
}, []);
const cancel = useCallback(() => {
setIsActive(false);
setIsPaused(false);
clearScrollTriggers();
}, []);
const toggleActive = useCallback(() => {
if (isActive) {
cancel();
return;
}
start();
}, [isActive]);
const pause = useCallback(() => {
setIsPaused(true);
}, []);
const resume = useCallback(() => {
setIsPaused(false);
}, []);
useEffect(() => {
if (!refOrCallback || !isActive) {
return () => {};
}
if (isPaused) {
clearScrollTriggers();
return () => {};
}
const scrollSpeedMs = scrollPerSecond * 1000;
if (isCallback) {
handleScrolling(
false,
scrollSpeedMs,
(id) => {
scrollTriggerTimer.current = id as NodeJS.Timeout;
},
refOrCallback,
clearScrollTriggers,
);
return () => clearScrollTriggers();
}
const element = refOrCallback.current;
if (!element) {
return () => {};
}
if (!elementStyle.current) {
elementStyle.current = getComputedStyle(element);
}
const isRTL = elementStyle.current.direction === 'rtl';
const handleScrollX = scrollDirection !== ScrollDirection.Y;
const handleScrollY = scrollDirection !== ScrollDirection.X;
const pxPerMsX = getPxPerMs(window.innerWidth, scrollAmountPercentage, scrollSpeedMs);
const pxPerMsY = getPxPerMs(window.innerHeight, scrollAmountPercentage, scrollSpeedMs);
handleScrolling(
smooth,
scrollSpeedMs,
(id, type) => {
switch (type) {
case 'interval':
scrollTriggerTimer.current = id as NodeJS.Timeout;
break;
case 'animationFrame':
scrollTriggerAnimationFrameId.current = id as number;
break;
default:
throw new Error(`Unexpected "type" (${type})`);
}
},
(elapsedTime) => {
element.scrollBy({
top: Number(handleScrollY) * getScrollAmount(pxPerMsY, elapsedTime, false, invert),
left: Number(handleScrollX) * getScrollAmount(pxPerMsX, elapsedTime, isRTL, invert),
// arg "smooth" triggers the interval so fast that using "behavior smooth" doesn't look smooth and also slows down the scrolling
behavior: smooth ? undefined : 'smooth',
});
},
clearScrollTriggers,
);
return () => clearScrollTriggers();
}, [refOrCallback, scrollPerSecond, scrollAmountPercentage, screenDimensions, isActive, isPaused, invert, smooth]);
return useMemo(
() => ({ isActive, isPaused, start, cancel, toggleActive, pause, resume }),
[isActive, isPaused, start, cancel, toggleActive, pause, resume],
);
};

View File

@@ -0,0 +1,34 @@
/*
* 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 { useLocation, useNavigate } from 'react-router-dom';
import { useCallback } from 'react';
import { AppRoutes } from '@/base/AppRoute.constants.ts';
import { useAppPageHistoryContext } from '@/base/contexts/AppPageHistoryContext.tsx';
const PAGES_TO_IGNORE: readonly RegExp[] = [/\/manga\/[0-9]+\/chapter\/[0-9]+/g];
export const useBackButton = () => {
const navigate = useNavigate();
const location = useLocation();
const history = useAppPageHistoryContext();
return useCallback(() => {
const isHistoryEmpty = !history.length;
const isLastPageInHistoryCurrentPage = history.length === 1 && history[0] === location.pathname;
const ignorePreviousPage = history.length && PAGES_TO_IGNORE.some((page) => !!history.slice(-2)[0].match(page));
const canNavigateBack = !ignorePreviousPage && !isHistoryEmpty && !isLastPageInHistoryCurrentPage;
if (canNavigateBack) {
navigate(-1);
return;
}
navigate(AppRoutes.library.path());
}, [history, location.pathname]);
};

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 { useEffect, useState } from 'react';
export const useDebounce = <Value>(value: Value, delay: number): Value => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};

View File

@@ -0,0 +1,53 @@
/*
* 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 { useCallback, useEffect, useState } from 'react';
import { NavigationType, useLocation, useNavigationType } from 'react-router-dom';
const MAX_DEPTH = 50;
export const useHistory = () => {
const location = useLocation();
const navigationType = useNavigationType();
const [history, setHistory] = useState<string[]>([location.pathname]);
const updateHistory = useCallback((newHistory: string[]) => {
// prevent the history from getting too large (only relevant in case the app never gets reloaded (e.g. browser F5,
// electron window gets closed))
// theoretically the history should be empty for the "base" pages (e.g. library, updates, ...), but since the browser
// navigation is used, opening another base page pushes this page to this history, as if it had a different depth
// than the current page (expected history: library -> manga -> reader,
// possible history: library -> updates -> settings -> library -> manga -> reader)
setHistory(newHistory.slice(-MAX_DEPTH));
}, []);
useEffect(() => {
const isLastPageInHistory = location.key === 'default';
const ignoreInitialPop = isLastPageInHistory && history.length === 1;
if (ignoreInitialPop) {
return;
}
switch (navigationType) {
case NavigationType.Pop:
updateHistory([...history.slice(0, -1)]);
break;
case NavigationType.Push:
updateHistory([...history, location.pathname + location.search]);
break;
case NavigationType.Replace:
updateHistory([...history.slice(0, -1), location.pathname + location.search]);
break;
default:
throw new Error(`Unexpected NavigationType "${navigationType}"`);
}
}, [location]);
return history;
};

View File

@@ -0,0 +1,51 @@
/*
* 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 { RefObject, useLayoutEffect, useState } from 'react';
export const useIntersectionObserver = (
ref: RefObject<HTMLElement | null> | HTMLElement | undefined | null,
callback: IntersectionObserverCallback,
{
ignoreInitialObserve = false,
root,
rootMargin,
threshold,
}: IntersectionObserverInit & { ignoreInitialObserve?: boolean } = {},
): (() => void) => {
const [disconnect, setDisconnect] = useState<() => void>(() => {});
useLayoutEffect(() => {
const element = ref instanceof HTMLElement ? ref : ref?.current;
if (!element) {
return () => {};
}
// gets immediately observed once on initial render
let isInitialObserve = true;
const intersectionObserver = new IntersectionObserver(
(...args) => {
if (ignoreInitialObserve && isInitialObserve) {
isInitialObserve = false;
return;
}
callback(...args);
},
{ root, rootMargin, threshold },
);
intersectionObserver.observe(element);
setDisconnect(() => () => intersectionObserver.disconnect());
return () => intersectionObserver.disconnect();
}, [ref, callback, ignoreInitialObserve, root, rootMargin, threshold]);
return disconnect;
};

View File

@@ -0,0 +1,228 @@
/*
* 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/.
*/
/**
* credit: (06.12.2024 - 00:11)
* MobileLikeScroller
* https://github.com/utsb-fmm/MobileLikeScroller/blob/main/mobilelikescroller.js
*/
import { MutableRefObject, useEffect, useRef, useState } from 'react';
import { ScrollDirection } from '@/base/Base.types.ts';
import { coerceIn } from '@/lib/HelperFunctions.ts';
type Positions = [OldestPos: number, SecondOldestPos: number, LatestPos: number];
type ClickTimes = [OldestTime: number, SecondOldestTime: number, LatestTime: number];
const OLDEST = 2;
const SECOND_OLDEST = 1;
const LATEST = 0;
const X = 0;
const Y = 1;
export const useMouseDragScroll = (
ref?: MutableRefObject<HTMLElement | null>,
scrollDirection: ScrollDirection = ScrollDirection.XY,
) => {
const [isDragging, setIsDragging] = useState(false);
const elementStyle = useRef<CSSStyleDeclaration>(undefined);
const previousClickPosX = useRef<Positions>([0, 0, 0]);
const previousClickPosY = useRef<Positions>([0, 0, 0]);
const previousClickTime = useRef<ClickTimes>([0, 0, 0]);
const scrollAtT0 = useRef<[ScrollLeft: number, ScrollTop: number]>([0, 0]);
const inertiaTimeInterval = useRef<NodeJS.Timeout>(undefined);
useEffect(() => {
const element = ref?.current;
if (!element) {
return () => {};
}
const isRTL = () => {
if (!elementStyle.current) {
elementStyle.current = getComputedStyle(element);
}
const isRTLDirection = elementStyle.current.direction === 'rtl';
const isFlexDirectionReversed = elementStyle.current.flexDirection === 'row-reverse';
return isRTLDirection || (!isRTLDirection && isFlexDirectionReversed);
};
const isTopReversed = () => {
if (!elementStyle.current) {
elementStyle.current = getComputedStyle(element);
}
return elementStyle.current.flexDirection === 'column-reverse';
};
const handleScrollX = scrollDirection !== ScrollDirection.Y;
const handleScrollY = scrollDirection !== ScrollDirection.X;
const clearInertiaInterval = () => clearInterval(inertiaTimeInterval.current);
const inertiaMove = () => {
const calcVelocity = (positions: Positions, clickTimes: ClickTimes, size: number): number =>
(((positions[LATEST] - positions[OLDEST]) / (clickTimes[LATEST] - clickTimes[OLDEST])) * 1000) / size;
const v0 = [
handleScrollX
? calcVelocity(previousClickPosX.current, previousClickTime.current, element.clientWidth)
: 0,
handleScrollY
? calcVelocity(previousClickPosY.current, previousClickTime.current, element.clientHeight)
: 0,
];
const a0V = (() => {
if (handleScrollX && handleScrollY) {
return Math.sqrt(v0[X] ** 2 + v0[Y] ** 2);
}
if (handleScrollY) {
return Math.abs(v0[Y]);
}
return Math.abs(v0[X]);
})();
const unitVector = [v0[X] / a0V, v0[Y] / a0V];
const a0VCoerced = coerceIn(1.2 * a0V, -12, 12);
const t = (Date.now() - previousClickTime.current[LATEST]) / 1000;
const v =
a0VCoerced - 14.278 * t + (75.24 * t ** 2) / a0VCoerced - (149.72 * t ** 3) / a0VCoerced / a0VCoerced;
const isValidVelocity = a0VCoerced !== 0 && v > 0 && !Number.isNaN(a0VCoerced);
if (!isValidVelocity) {
clearInertiaInterval();
return;
}
const calcDelta = (size: number, unit: number): number =>
size *
unit *
(a0VCoerced * t -
7.1397 * t ** 2 +
(25.08 * t ** 3) / a0VCoerced -
(37.43 * t ** 4) / a0VCoerced / a0VCoerced);
const delta = [
calcDelta(element.clientWidth, unitVector[X]),
calcDelta(element.clientHeight, unitVector[Y]),
];
const maxScrollPos = [
element.scrollWidth - element.clientWidth,
element.scrollHeight - element.clientHeight,
];
const newScrollPos = [
coerceIn(scrollAtT0.current[X] - delta[X], isRTL() ? -maxScrollPos[X] : 0, maxScrollPos[X]),
coerceIn(scrollAtT0.current[Y] - delta[Y], isTopReversed() ? -maxScrollPos[Y] : 0, maxScrollPos[Y]),
];
const isScrollXPossible = newScrollPos[X] !== 0 || newScrollPos[X] !== maxScrollPos[X];
const isScrollYPossible = newScrollPos[Y] !== 0 || newScrollPos[Y] !== maxScrollPos[Y];
const isScrollPossible = isScrollXPossible || isScrollYPossible;
if (!isScrollPossible) {
clearInertiaInterval();
}
if (handleScrollX) {
element.scrollLeft = newScrollPos[X];
}
if (handleScrollY) {
element.scrollTop = newScrollPos[Y];
}
};
let isHandlingMouseMoveEvents = false;
const shouldStartHandlingMouseMoveEvents = (e: MouseEvent) => {
if (isHandlingMouseMoveEvents) {
return true;
}
const hasScrollBar = [
element.clientHeight < element.scrollHeight,
element.clientWidth < element.scrollWidth,
];
const didPosChange = [
Math.abs(previousClickPosX.current[LATEST] - e.pageX) > 0,
Math.abs(previousClickPosY.current[LATEST] - e.pageY) > 0,
];
return (hasScrollBar[X] && didPosChange[X]) || (hasScrollBar[Y] && didPosChange[Y]);
};
const handleMouseMove = (e: MouseEvent) => {
if (!shouldStartHandlingMouseMoveEvents(e)) {
return;
}
isHandlingMouseMoveEvents = true;
setIsDragging(true);
previousClickPosX.current = [...(previousClickPosX.current.slice(1) as [number, number]), e.pageX];
previousClickPosY.current = [...(previousClickPosY.current.slice(1) as [number, number]), e.pageY];
previousClickTime.current = [...(previousClickTime.current.slice(1) as [number, number]), Date.now()];
if (handleScrollX) {
element.scrollLeft += previousClickPosX.current[LATEST] - previousClickPosX.current[SECOND_OLDEST];
}
if (handleScrollY) {
element.scrollTop += previousClickPosY.current[LATEST] - previousClickPosY.current[SECOND_OLDEST];
}
};
const handleMouseUp = () => {
element.removeEventListener('mousemove', handleMouseMove);
element.removeEventListener('mouseup', handleMouseUp);
// move disabling drag handling to next event loop cycle so that e.g. the resulting mouse click at the end of the dragging can be ignored
setTimeout(() => {
isHandlingMouseMoveEvents = false;
setIsDragging(false);
}, 0);
scrollAtT0.current = [element.scrollLeft, element.scrollTop];
inertiaTimeInterval.current = setInterval(inertiaMove, 16);
};
const handleMouseDown = (e: MouseEvent) => {
const isLeftMouseButton = e.button === 0;
if (!isLeftMouseButton) {
return;
}
e.preventDefault();
previousClickPosX.current = [e.pageX, e.pageX, e.pageX];
previousClickPosY.current = [e.pageY, e.pageY, e.pageY];
previousClickTime.current = [Date.now() - 2, Date.now() - 1, Date.now()];
element.addEventListener('mousemove', handleMouseMove);
element.addEventListener('mouseup', handleMouseUp);
clearInertiaInterval();
};
element.addEventListener('mousedown', handleMouseDown);
element.addEventListener('wheel', clearInertiaInterval);
return () => {
element.removeEventListener('mousedown', handleMouseDown);
element.removeEventListener('wheel', clearInertiaInterval);
clearInertiaInterval();
};
}, [ref, scrollDirection]);
return isDragging;
};

View File

@@ -0,0 +1,31 @@
/*
* 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 { useLocalStorage } from '@/base/hooks/useStorage.tsx';
export const getPersistedServerSetting = <T,>(serverValue: T | undefined, lastValue: T): T => {
const isDisabled = serverValue === 0;
if (isDisabled) {
return lastValue;
}
return serverValue ?? lastValue;
};
export const usePersistedValue = <T,>(
key: string,
defaultValue: T,
currentValue: T | undefined,
getCurrentValue: (currentValue: T | undefined, persistedValue: T) => T,
): [T, (value: T) => void] => {
const [persistedValue, setPersistedValue] = useLocalStorage(key, defaultValue);
const value = getCurrentValue(currentValue, persistedValue);
return [value, setPersistedValue];
};

View File

@@ -0,0 +1,33 @@
/*
* 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 { RefObject, useLayoutEffect, useState } from 'react';
export const useResizeObserver = (
ref: RefObject<HTMLElement | null> | HTMLElement | undefined | null,
callback: ResizeObserverCallback,
): (() => void) => {
const [disconnect, setDisconnect] = useState<() => void>(() => {});
useLayoutEffect(() => {
const element = ref instanceof HTMLElement ? ref : ref?.current;
if (!element) {
return () => {};
}
const resizeObserver = new ResizeObserver(callback);
resizeObserver.observe(element);
setDisconnect(() => () => resizeObserver.disconnect());
return () => resizeObserver.disconnect();
}, [ref, callback]);
return disconnect;
};

View File

@@ -0,0 +1,101 @@
/*
* 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 { Dispatch, Reducer, SetStateAction, useCallback, useMemo, useReducer, useSyncExternalStore } from 'react';
import { AppStorage, Storage } from '@/lib/storage/AppStorage.ts';
import { jsonSaveParse } from '@/lib/HelperFunctions.ts';
const subscribeToStorageUpdates = (callback: () => void) => {
window.addEventListener('storage', callback);
return () => window.removeEventListener('storage', callback);
};
function useStorage<T>(storage: Storage, key: string, defaultValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>];
function useStorage<T = undefined>(
storage: Storage,
key: string,
): [T | undefined, Dispatch<SetStateAction<T | undefined>>];
function useStorage<T>(
storage: Storage,
key: string,
defaultValue?: T | (() => T) | undefined,
): [T | undefined, Dispatch<SetStateAction<T | undefined>>] {
const initialState = defaultValue instanceof Function ? defaultValue() : defaultValue;
const storedValueRaw = useSyncExternalStore(subscribeToStorageUpdates, () => storage.getItem(key));
const setValue = useCallback<React.Dispatch<React.SetStateAction<T | undefined>>>(
(value) => {
// Allow value to be a function so we have same API as useState
const valueToStore = (() => {
if (value instanceof Function) {
const previousValue = storage.getItemParsed(key, initialState);
return value(previousValue);
}
return value;
})();
storage.setItem(key, valueToStore);
},
[key],
);
const storedValue = useMemo(() => {
if (storedValueRaw === null) {
return initialState;
}
return jsonSaveParse(storedValueRaw) ?? storedValueRaw;
}, [storedValueRaw, key]);
return [storedValue, setValue];
}
const useReducerStorage = <S, A>(
storage: Storage,
reducer: Reducer<S, A>,
key: string,
defaultState: S | (() => S),
) => {
const [storedValue, setValue] = useStorage(storage, key, defaultState);
return useReducer((state: S, action: A): S => {
const newState = reducer(state, action);
setValue(newState);
return newState;
}, storedValue);
};
export function useLocalStorage<T>(key: string, defaultValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>];
export function useLocalStorage<T = undefined>(key: string): [T | undefined, Dispatch<SetStateAction<T | undefined>>];
export function useLocalStorage<T>(
key: string,
defaultValue?: T | undefined | (() => T | undefined),
): [T | undefined, Dispatch<SetStateAction<T | undefined>>] {
return useStorage(AppStorage.local, key, defaultValue);
}
export function useReducerLocalStorage<S, A>(reducer: Reducer<S, A>, key: string, defaultState: S | (() => S)) {
return useReducerStorage(AppStorage.local, reducer, key, defaultState);
}
export function useSessionStorage<T>(key: string, defaultValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>];
export function useSessionStorage<T = undefined>(key: string): [T | undefined, Dispatch<SetStateAction<T | undefined>>];
export function useSessionStorage<T>(
key: string,
defaultValue?: T | (() => T),
): [T | undefined, Dispatch<SetStateAction<T | undefined>>] {
return useStorage(AppStorage.session, key, defaultValue);
}
export function useReducerSessionStorage<S, A>(reducer: Reducer<S, A>, key: string, defaultState: S | (() => S)) {
return useReducerStorage(AppStorage.session, reducer, key, defaultState);
}

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/.
*/
import { Theme } from '@mui/material/styles';
// use CSSObject instead of SxProps<Theme> because this completely fucks over typescript by causing an out of memory error during compilation
type CSSObject = ReturnType<Theme['applyStyles']>;
const emptyStyle = {};
export const applyStyles = (isActive: boolean, styling: CSSObject): CSSObject => (isActive ? styling : emptyStyle);

View File

@@ -0,0 +1,50 @@
/*
* 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 { createRoot } from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
import { ConfirmDialog } from '@/base/components/modals/ConfirmDialog.tsx';
import { ControlledPromise } from '@/lib/ControlledPromise.ts';
import { getCurrentTheme } from '@/features/theme/services/ThemeCreator.ts';
export const awaitConfirmation = async (
dialogProps: Omit<React.ComponentProps<typeof ConfirmDialog>, 'onCancel' | 'onConfirm'>,
) => {
const dialogContainer = document.createElement('div');
document.body.appendChild(dialogContainer);
const root = createRoot(dialogContainer);
const confirmationPromise = new ControlledPromise();
const handleConfirmation = (accepted: boolean) => {
if (accepted) {
confirmationPromise.resolve();
} else {
confirmationPromise.reject(new Error('Confirmation declined'));
}
root.unmount();
document.body.removeChild(dialogContainer);
};
root.render(
<ThemeProvider theme={getCurrentTheme()}>
<ConfirmDialog
{...dialogProps}
onExtra={() => {
handleConfirmation(false);
dialogProps.onExtra?.();
}}
onCancel={() => handleConfirmation(false)}
onConfirm={() => handleConfirmation(true)}
/>
</ThemeProvider>,
);
return confirmationPromise.promise;
};

View File

@@ -0,0 +1,71 @@
/*
* 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 dayjs, { Dayjs } from 'dayjs';
import { t } from 'i18next';
export const timeFormatter = new Intl.DateTimeFormat(navigator.language, { hour: '2-digit', minute: '2-digit' });
export const dateFormatter = new Intl.DateTimeFormat(navigator.language, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
export const dateTimeFormatter = new Intl.DateTimeFormat(navigator.language, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
export const epochToDate = (epoch: number): Dayjs => dayjs.unix(epoch);
export const isSameDay = (first: Dayjs, second: Dayjs): boolean => first.isSame(second, 'day');
/**
* Returns a string in localized format for the passed date.
*
* In case the date is from today or yesterday a special string will be returned including "Today"
* or "Yesterday".
* Optionally this special string can include the localized time of the passed date ("Today/Yesterday at HH:mm").
*
* @example
* const today = dayjs();
* const yesterday = today.subtract(1, 'day');
* const someDate = dayjs('1377-04-20');
*
* const todayAsString = getDateString(today); // => "Today"
* const yesterdayAsString = getDateString(yesterday, true) // => "Yesterday at 02:50 AM"
* const someDate = getDateString(someDate) // => "04/20/1337"
*
*
* @param date
* @param withTime
*/
export const getDateString = (date: Dayjs | number, withTime: boolean = false) => {
const actualDate = date instanceof dayjs ? date : dayjs(date);
const timeString = timeFormatter.format(actualDate.toDate());
if (actualDate.isToday()) {
if (withTime) {
return t('global.date.label.today_at', { timeString });
}
return t('global.date.label.today');
}
if (actualDate.isYesterday()) {
if (withTime) {
return t('global.date.label.yesterday_at', { timeString });
}
return t('global.date.label.yesterday');
}
return dateFormatter.format(actualDate.toDate());
};

145
src/base/utils/Languages.ts Normal file
View File

@@ -0,0 +1,145 @@
/*
* 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 { t } from 'i18next';
import { ISOLanguage, IsoLanguages } from '@/base/IsoLanguages.ts';
import { TranslationKey } from '@/base/Base.types.ts';
export enum DefaultLanguage {
ALL = 'all',
OTHER = 'other',
LOCAL_SOURCE = 'localsourcelang',
PINNED = 'pinned',
LAST_USED_SOURCE = 'last_used_source',
}
const DEFAULT_LANGUAGE_TO_TRANSLATION: Record<DefaultLanguage, TranslationKey> = {
[DefaultLanguage.ALL]: 'extension.language.all',
[DefaultLanguage.OTHER]: 'extension.language.other',
[DefaultLanguage.LOCAL_SOURCE]: 'extension.language.other',
[DefaultLanguage.PINNED]: 'global.label.pinned',
[DefaultLanguage.LAST_USED_SOURCE]: 'global.label.last_used',
};
type LanguageObject = ISOLanguage & { orgCode: string; isoCode: string };
function getISOLanguage(code: string): LanguageObject | null {
if (IsoLanguages[code]) {
return {
...IsoLanguages[code],
orgCode: code,
isoCode: code,
};
}
if (IsoLanguages[code.toLocaleLowerCase()]) {
return {
...IsoLanguages[code.toLocaleLowerCase()],
orgCode: code,
isoCode: code.toLocaleLowerCase(),
};
}
const whereToCut = code.indexOf('-') !== -1 ? code.indexOf('-') : code.length;
const processedCode = code.toLocaleLowerCase().substring(0, whereToCut);
if (IsoLanguages[processedCode]) {
return {
...IsoLanguages[processedCode],
orgCode: code,
isoCode: processedCode,
};
}
return null;
}
export function getLanguage(code: string): LanguageObject {
const isoLanguage = getISOLanguage(code);
if (isoLanguage) {
return isoLanguage;
}
return {
orgCode: code,
isoCode: code,
name: t('global.language.label.language_with_code', { code }),
nativeName: t('global.language.label.language_with_code', { code }),
};
}
export function languageCodeToName(code: string): string {
const isCustomLanguage = Object.keys(DEFAULT_LANGUAGE_TO_TRANSLATION).includes(code);
if (isCustomLanguage) {
return t(DEFAULT_LANGUAGE_TO_TRANSLATION[code as DefaultLanguage]);
}
return getLanguage(code).nativeName;
}
export const toUniqueLanguageCodes = (codes: string[]): string[] => {
const languages = codes.map((code) => getLanguage(code));
const languagesByIsoCode = Object.groupBy(languages, (language) => language.isoCode);
return Object.entries(languagesByIsoCode)
.filter(([, languagesOfIsoCode]) => !!languagesOfIsoCode?.length)
.map(([, languagesOfIsoCode]) => languagesOfIsoCode![0].orgCode);
};
export const toComparableLanguage = (code: string): string => getLanguage(code).isoCode;
export const toComparableLanguages = (codes: string[]): string[] => codes.map(toComparableLanguage);
function defaultNativeLang(): readonly string[] {
const preferredLanguages = toUniqueLanguageCodes([...navigator.languages]);
if (!preferredLanguages.length) {
return ['en'];
}
return preferredLanguages;
}
export function getDefaultLanguages(): string[] {
return [...defaultNativeLang(), DefaultLanguage.ALL];
}
/**
* Sort languages by their native name.
* Custom languages are optionally treated specially:
* - All: first
* - Other: last
*/
export const languageSortComparator = (a: string, b: string, specialCustomLanguagesHandling?: boolean) => {
const isALanguageAll = a === DefaultLanguage.ALL;
const isALanguageOther = a === DefaultLanguage.OTHER || a === DefaultLanguage.LOCAL_SOURCE;
const isBLanguageAll = b === DefaultLanguage.ALL;
const isBLanguageOther = b === DefaultLanguage.OTHER || b === DefaultLanguage.LOCAL_SOURCE;
if (specialCustomLanguagesHandling) {
if (isALanguageAll || isBLanguageOther) {
return -1;
}
if (isALanguageOther || isBLanguageAll) {
return 1;
}
}
return languageCodeToName(a).localeCompare(languageCodeToName(b));
};
/**
* Sort languages by their native name.
* Custom languages are treated specially:
* - All: first
* - Other: last
*/
export const languageSpecialSortComparator = (a: string, b: string) => languageSortComparator(a, b, true);

View File

@@ -0,0 +1,11 @@
/*
* 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 { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
export const lazyLoadFallback = { fallback: <LoadingPlaceholder /> };

View File

@@ -0,0 +1,132 @@
/*
* 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 useMediaQuery from '@mui/material/useMediaQuery';
import { Breakpoint, SxProps, Theme } from '@mui/material/styles';
import { useCallback, useState } from 'react';
import { getCurrentTheme } from '@/features/theme/services/ThemeCreator.ts';
import { ThemeMode } from '@/features/theme/AppThemeContext.tsx';
import { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
export class MediaQuery {
static readonly MOBILE_WIDTH: Breakpoint | number = 'sm';
static readonly TABLET_WIDTH: Breakpoint | number = 1025;
static isTouchDevice(): boolean {
return window.matchMedia('not (pointer: fine)').matches;
}
static useIsTouchDevice(): boolean {
return useMediaQuery('not (pointer: fine)');
}
static useIsBelowWidth(breakpoint: Breakpoint | number): boolean {
return useMediaQuery(getCurrentTheme().breakpoints.down(breakpoint));
}
static useIsMobileWidth(): boolean {
return this.useIsBelowWidth(this.MOBILE_WIDTH);
}
static useIsTabletWidth(): boolean {
return this.useIsBelowWidth(this.TABLET_WIDTH);
}
private static getScrollbarSize(type: 'height' | 'width'): number {
const outer = document.createElement('div');
outer.style.position = 'absolute';
outer.style.top = '-9999px';
outer.style.visibility = 'hidden';
outer.style.overflow = 'scroll';
document.body.appendChild(outer);
const inner = document.createElement('div');
inner.style.width = '100%';
inner.style.height = '100%';
outer.appendChild(inner);
const width = outer.offsetWidth - inner.offsetWidth;
const height = outer.offsetHeight - inner.offsetHeight;
document.body.removeChild(outer);
return type === 'height' ? height : width;
}
static useGetScrollbarSize(
type: 'height' | 'width',
element: HTMLElement | null = document.documentElement,
): number {
const [scrollbarSize, setScrollbarSize] = useState(0);
useResizeObserver(
element,
useCallback(() => {
const hasYScrollbar = !!(element!.scrollHeight - element!.clientHeight);
const hasXScrollbar = !!(element!.scrollWidth - element!.clientWidth);
const hasScrollbar = (type === 'height' && hasYScrollbar) || (type === 'width' && hasXScrollbar);
if (hasScrollbar) {
setScrollbarSize(this.getScrollbarSize(type));
return;
}
setScrollbarSize(0);
}, [element]),
);
return scrollbarSize;
}
static getSystemThemeMode(): Exclude<ThemeMode, 'system'> {
const prefersDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDarkMode ? ThemeMode.DARK : ThemeMode.LIGHT;
}
static getThemeMode(themeMode: ThemeMode): Exclude<ThemeMode, 'system'> {
const isSystemMode = themeMode === ThemeMode.SYSTEM;
if (isSystemMode) {
return this.getSystemThemeMode();
}
return themeMode;
}
static listenToSystemThemeChange(onChange: (themeMode: Exclude<ThemeMode, 'system'>) => void): () => void {
const handleSystemThemeModeChange = (e: MediaQueryListEvent) => {
onChange(e.matches ? ThemeMode.DARK : ThemeMode.LIGHT);
};
const matchSystemThemeMode = window.matchMedia('(prefers-color-scheme: dark)');
matchSystemThemeMode.addEventListener('change', handleSystemThemeModeChange);
return () => matchSystemThemeMode.removeEventListener('change', handleSystemThemeModeChange);
}
static usePreventMobileContextMenu() {
const isTouchDevice = MediaQuery.useIsTouchDevice();
return useCallback(
(e: React.MouseEvent<any, MouseEvent>) => {
if (isTouchDevice) {
e.preventDefault();
}
},
[isTouchDevice],
);
}
static preventMobileContextMenuSx(): SxProps<Theme> {
return {
userSelect: 'none',
'-webkit-touch-callout': 'none',
};
}
}

View File

@@ -0,0 +1,14 @@
/*
* 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 shouldForwardProp =
<TCustomProps extends Record<string, unknown>>(customProps: TupleUnion<keyof TCustomProps>) =>
(prop: string): boolean =>
// @ts-ignore - TS2589: Type instantiation is excessively deep and possibly infinite.
// this function should never be used without a strict type, thus, this error can be ignored
!customProps.includes(prop);

18
src/base/utils/Strings.ts Normal file
View File

@@ -0,0 +1,18 @@
/*
* 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 baseCleanup = (str: string) => str.toLowerCase().trim();
export const enhancedCleanup = (str: string): string =>
baseCleanup(str)
.normalize('NFKC')
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim();
export const reverseString = (str: string, separator: string = ''): string =>
str.split(separator).reverse().join(separator);

28
src/base/utils/Toast.ts Normal file
View File

@@ -0,0 +1,28 @@
/*
* 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 { enqueueSnackbar, OptionsObject, SnackbarKey } from 'notistack';
export function makeToast(message: string, severity?: OptionsObject['variant'], description?: string): SnackbarKey;
export function makeToast(message: string, options?: OptionsObject, description?: string): SnackbarKey;
export function makeToast(
message: string,
options: OptionsObject['variant'] | OptionsObject = 'default',
description?: string,
): SnackbarKey {
const variant = typeof options === 'string' ? options : undefined;
const snackbarOptions = typeof options === 'object' ? options : {};
return enqueueSnackbar(message, {
variant,
...snackbarOptions,
// @ts-ignore - TS2353, "notistack" is outdated and the provided way to define custom props is not working, however,
// everything in the options object gets passed to the custom snackbar component
description,
});
}

View File

@@ -0,0 +1,23 @@
/*
* 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 getNextRotationValue = <Value>(
indexOfValue: number,
values: Value[],
isDefaultable?: boolean,
): Value | undefined => {
const nextValueIndex = (indexOfValue + 1) % values.length;
const wasLastValue = nextValueIndex === 0;
const isDefaultNextValue = !!isDefaultable && wasLastValue;
if (isDefaultNextValue) {
return undefined;
}
return values[(indexOfValue + 1) % values.length];
};

View File

@@ -0,0 +1,11 @@
/*
* 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 function cloneObject<T extends object>(obj: T) {
return JSON.parse(JSON.stringify(obj)) as T;
}