Move "core" folder out of "features" into "base"
This commit is contained in:
208
src/base/AppRoute.constants.ts
Normal file
208
src/base/AppRoute.constants.ts
Normal 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
15
src/base/Asserts.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
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
76
src/base/Base.types.ts
Normal 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
805
src/base/IsoLanguages.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
128
src/base/components/AppbarSearch.tsx
Normal file
128
src/base/components/AppbarSearch.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
20
src/base/components/CustomTooltip.tsx
Normal file
20
src/base/components/CustomTooltip.tsx
Normal 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>
|
||||
);
|
||||
105
src/base/components/GridLayouts.tsx
Normal file
105
src/base/components/GridLayouts.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
225
src/base/components/SpinnerImage.tsx
Normal file
225
src/base/components/SpinnerImage.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
55
src/base/components/buttons/ButtonSelect.tsx
Normal file
55
src/base/components/buttons/ButtonSelect.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
src/base/components/buttons/CustomButton.tsx
Normal file
32
src/base/components/buttons/CustomButton.tsx
Normal 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>
|
||||
),
|
||||
);
|
||||
29
src/base/components/buttons/CustomButtonIcon.tsx
Normal file
29
src/base/components/buttons/CustomButtonIcon.tsx
Normal 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>
|
||||
),
|
||||
);
|
||||
37
src/base/components/buttons/ResetButton.tsx
Normal file
37
src/base/components/buttons/ResetButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
src/base/components/buttons/StyledFab.tsx
Normal file
23
src/base/components/buttons/StyledFab.tsx
Normal 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;
|
||||
86
src/base/components/buttons/ValueRotationButton.tsx
Normal file
86
src/base/components/buttons/ValueRotationButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
74
src/base/components/downloads/DownloadStateIndicator.tsx
Normal file
74
src/base/components/downloads/DownloadStateIndicator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
123
src/base/components/feedback/EmptyView.tsx
Normal file
123
src/base/components/feedback/EmptyView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/base/components/feedback/EmptyViewAbsoluteCentered.tsx
Normal file
22
src/base/components/feedback/EmptyViewAbsoluteCentered.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
91
src/base/components/feedback/ErrorBoundary.tsx
Normal file
91
src/base/components/feedback/ErrorBoundary.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
53
src/base/components/feedback/LoadingPlaceholder.tsx
Normal file
53
src/base/components/feedback/LoadingPlaceholder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/base/components/feedback/Progress.tsx
Normal file
34
src/base/components/feedback/Progress.tsx
Normal 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>
|
||||
);
|
||||
117
src/base/components/feedback/SnackbarWithDescription.tsx
Normal file
117
src/base/components/feedback/SnackbarWithDescription.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
29
src/base/components/inputs/ButtonSelectInput.tsx
Normal file
29
src/base/components/inputs/ButtonSelectInput.tsx
Normal 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>
|
||||
);
|
||||
16
src/base/components/inputs/CheckboxContainer.ts
Normal file
16
src/base/components/inputs/CheckboxContainer.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
export const CheckboxContainer = styled('div')({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: '170px',
|
||||
overflow: 'auto',
|
||||
});
|
||||
19
src/base/components/inputs/CheckboxInput.tsx
Normal file
19
src/base/components/inputs/CheckboxInput.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
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} />
|
||||
);
|
||||
108
src/base/components/inputs/LanguageSelect.tsx
Normal file
108
src/base/components/inputs/LanguageSelect.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
src/base/components/inputs/PasswordTextField.tsx
Normal file
47
src/base/components/inputs/PasswordTextField.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
19
src/base/components/inputs/RadioInput.tsx
Normal file
19
src/base/components/inputs/RadioInput.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
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} />
|
||||
);
|
||||
37
src/base/components/inputs/SearchTextField.tsx
Normal file
37
src/base/components/inputs/SearchTextField.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
19
src/base/components/inputs/Select.tsx
Normal file
19
src/base/components/inputs/Select.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
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>
|
||||
);
|
||||
41
src/base/components/inputs/SliderInput.tsx
Normal file
41
src/base/components/inputs/SliderInput.tsx
Normal 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>
|
||||
);
|
||||
23
src/base/components/inputs/SortRadioInput.tsx
Normal file
23
src/base/components/inputs/SortRadioInput.tsx
Normal 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}
|
||||
/>
|
||||
));
|
||||
48
src/base/components/inputs/ThreeStateCheckbox.tsx
Normal file
48
src/base/components/inputs/ThreeStateCheckbox.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
19
src/base/components/inputs/ThreeStateCheckboxInput.tsx
Normal file
19
src/base/components/inputs/ThreeStateCheckboxInput.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
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} />
|
||||
);
|
||||
14
src/base/components/lists/ListItemLink.tsx
Normal file
14
src/base/components/lists/ListItemLink.tsx
Normal 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} />;
|
||||
}
|
||||
41
src/base/components/lists/cards/ListCardAvatar.tsx
Normal file
41
src/base/components/lists/cards/ListCardAvatar.tsx
Normal 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>
|
||||
);
|
||||
27
src/base/components/lists/cards/ListCardContent.tsx
Normal file
27
src/base/components/lists/cards/ListCardContent.tsx
Normal 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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
51
src/base/components/menu/IconMenuItem.tsx
Normal file
51
src/base/components/menu/IconMenuItem.tsx
Normal 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>
|
||||
),
|
||||
);
|
||||
35
src/base/components/menu/Menu.tsx
Normal file
35
src/base/components/menu/Menu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
43
src/base/components/menu/Menu.utils.ts
Normal file
43
src/base/components/menu/Menu.utils.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { 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;
|
||||
27
src/base/components/menu/MenuItem.tsx
Normal file
27
src/base/components/menu/MenuItem.tsx
Normal 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>
|
||||
);
|
||||
235
src/base/components/menu/NestedMenuItem.tsx
Normal file
235
src/base/components/menu/NestedMenuItem.tsx
Normal 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 };
|
||||
113
src/base/components/modals/ConfirmDialog.tsx
Normal file
113
src/base/components/modals/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
36
src/base/components/modals/OptionsPanel.tsx
Normal file
36
src/base/components/modals/OptionsPanel.tsx
Normal 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>
|
||||
);
|
||||
55
src/base/components/modals/OptionsTabs.tsx
Normal file
55
src/base/components/modals/OptionsTabs.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
125
src/base/components/settings/CheckboxListSetting.tsx
Normal file
125
src/base/components/settings/CheckboxListSetting.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
src/base/components/settings/DateSetting.tsx
Normal file
153
src/base/components/settings/DateSetting.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
245
src/base/components/settings/MutableListSetting.tsx
Normal file
245
src/base/components/settings/MutableListSetting.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
217
src/base/components/settings/NumberSetting.tsx
Normal file
217
src/base/components/settings/NumberSetting.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
155
src/base/components/settings/SelectSetting.tsx
Normal file
155
src/base/components/settings/SelectSetting.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
126
src/base/components/settings/TimeSetting.tsx
Normal file
126
src/base/components/settings/TimeSetting.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
40
src/base/components/settings/text/TextSetting.tsx
Normal file
40
src/base/components/settings/text/TextSetting.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
111
src/base/components/settings/text/TextSettingDialog.tsx
Normal file
111
src/base/components/settings/text/TextSettingDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
26
src/base/components/tabs/TabPanel.tsx
Normal file
26
src/base/components/tabs/TabPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/base/components/tabs/TabsMenu.tsx
Normal file
46
src/base/components/tabs/TabsMenu.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
15
src/base/components/tabs/TabsWrapper.tsx
Normal file
15
src/base/components/tabs/TabsWrapper.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import Box, { BoxProps } from '@mui/material/Box';
|
||||
|
||||
export const TabsWrapper = ({ children, ...props }: BoxProps) => (
|
||||
<Box {...props} sx={{ ...props.sx, position: 'relative', height: `100%` }}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
23
src/base/components/texts/Kbd.tsx
Normal file
23
src/base/components/texts/Kbd.tsx
Normal 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',
|
||||
}));
|
||||
41
src/base/components/texts/Metadata.tsx
Normal file
41
src/base/components/texts/Metadata.tsx
Normal 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>
|
||||
);
|
||||
35
src/base/components/texts/Superscript.tsx
Normal file
35
src/base/components/texts/Superscript.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
41
src/base/components/texts/TypographyMaxLines.tsx
Normal file
41
src/base/components/texts/TypographyMaxLines.tsx
Normal 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',
|
||||
}));
|
||||
28
src/base/components/virtuoso/StyledGroupHeader.tsx
Normal file
28
src/base/components/virtuoso/StyledGroupHeader.tsx
Normal 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),
|
||||
},
|
||||
}));
|
||||
17
src/base/components/virtuoso/StyledGroupItemWrapper.tsx
Normal file
17
src/base/components/virtuoso/StyledGroupItemWrapper.tsx
Normal 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),
|
||||
})),
|
||||
);
|
||||
49
src/base/components/virtuoso/StyledGroupedVirtuoso.tsx
Normal file
49
src/base/components/virtuoso/StyledGroupedVirtuoso.tsx
Normal 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'})`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
55
src/base/contexts/AppContext.tsx
Normal file
55
src/base/contexts/AppContext.tsx
Normal 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>
|
||||
);
|
||||
20
src/base/contexts/AppPageHistoryContext.tsx
Normal file
20
src/base/contexts/AppPageHistoryContext.tsx
Normal 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>;
|
||||
};
|
||||
39
src/base/hoc/withPropsFrom.tsx
Normal file
39
src/base/hoc/withPropsFrom.tsx
Normal 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} />;
|
||||
}),
|
||||
);
|
||||
203
src/base/hooks/useAutomaticScrolling.ts
Normal file
203
src/base/hooks/useAutomaticScrolling.ts
Normal 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],
|
||||
);
|
||||
};
|
||||
34
src/base/hooks/useBackButton.ts
Normal file
34
src/base/hooks/useBackButton.ts
Normal 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]);
|
||||
};
|
||||
25
src/base/hooks/useDebounce.ts
Normal file
25
src/base/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { 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;
|
||||
};
|
||||
53
src/base/hooks/useHistory.ts
Normal file
53
src/base/hooks/useHistory.ts
Normal 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;
|
||||
};
|
||||
51
src/base/hooks/useIntersectionObserver.tsx
Normal file
51
src/base/hooks/useIntersectionObserver.tsx
Normal 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;
|
||||
};
|
||||
228
src/base/hooks/useMouseDragScroll.tsx
Normal file
228
src/base/hooks/useMouseDragScroll.tsx
Normal 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;
|
||||
};
|
||||
31
src/base/hooks/usePersistedValue.tsx
Normal file
31
src/base/hooks/usePersistedValue.tsx
Normal 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];
|
||||
};
|
||||
33
src/base/hooks/useResizeObserver.tsx
Normal file
33
src/base/hooks/useResizeObserver.tsx
Normal 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;
|
||||
};
|
||||
101
src/base/hooks/useStorage.tsx
Normal file
101
src/base/hooks/useStorage.tsx
Normal 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);
|
||||
}
|
||||
15
src/base/utils/ApplyStyles.ts
Normal file
15
src/base/utils/ApplyStyles.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
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);
|
||||
50
src/base/utils/AwaitableDialog.tsx
Normal file
50
src/base/utils/AwaitableDialog.tsx
Normal 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;
|
||||
};
|
||||
71
src/base/utils/DateHelper.ts
Normal file
71
src/base/utils/DateHelper.ts
Normal 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
145
src/base/utils/Languages.ts
Normal 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);
|
||||
11
src/base/utils/LazyLoad.tsx
Normal file
11
src/base/utils/LazyLoad.tsx
Normal 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 /> };
|
||||
132
src/base/utils/MediaQuery.tsx
Normal file
132
src/base/utils/MediaQuery.tsx
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
14
src/base/utils/ShouldForwardProp.ts
Normal file
14
src/base/utils/ShouldForwardProp.ts
Normal 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
18
src/base/utils/Strings.ts
Normal 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
28
src/base/utils/Toast.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
23
src/base/utils/ValueRotationButton.utils.ts
Normal file
23
src/base/utils/ValueRotationButton.utils.ts
Normal 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];
|
||||
};
|
||||
11
src/base/utils/cloneObject.tsx
Normal file
11
src/base/utils/cloneObject.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user