├── .eslintignore ├── .prettierignore ├── src ├── meridian │ ├── icons │ │ ├── index.ts │ │ ├── logo.png │ │ ├── romania.png │ │ ├── united-states.png │ │ └── types.ts │ ├── session │ │ ├── index.ts │ │ └── state │ │ │ ├── types.ts │ │ │ ├── index.ts │ │ │ ├── selectors.ts │ │ │ ├── reducer.ts │ │ │ └── actions.ts │ ├── state │ │ ├── index.ts │ │ ├── sagas │ │ │ ├── index.ts │ │ │ └── startupSaga.ts │ │ ├── types.ts │ │ └── store.ts │ ├── testing │ │ ├── index.ts │ │ └── helpers.tsx │ ├── torrentContent │ │ ├── index.ts │ │ └── state │ │ │ ├── index.ts │ │ │ ├── selectors.ts │ │ │ ├── reducer.ts │ │ │ └── saga.ts │ ├── torrentFilters │ │ ├── index.ts │ │ └── state │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ ├── selectors.ts │ │ │ ├── actions.ts │ │ │ └── reducer.ts │ ├── torrentTrackers │ │ ├── index.ts │ │ └── state │ │ │ ├── index.ts │ │ │ ├── selectors.ts │ │ │ ├── reducer.ts │ │ │ └── saga.ts │ ├── transformers │ │ ├── index.ts │ │ └── mainDataTransformer.ts │ ├── api │ │ └── index.ts │ ├── home │ │ ├── index.ts │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── useFetchTimer.ts │ │ │ ├── useManageSelection.ts │ │ │ ├── usePagination.ts │ │ │ ├── useFilteredTorrents.ts │ │ │ └── useHeaderMenuItems.tsx │ │ └── headerContent.tsx │ ├── login │ │ ├── index.ts │ │ ├── useNavigateToLogin.ts │ │ ├── useLoginForm.tsx │ │ └── loginPage.tsx │ ├── tags │ │ ├── index.ts │ │ ├── state │ │ │ ├── index.ts │ │ │ ├── selectors.ts │ │ │ ├── reducer.ts │ │ │ └── saga.ts │ │ ├── modals │ │ │ ├── index.ts │ │ │ ├── useCreateTagModal.tsx │ │ │ └── useTagsModal.tsx │ │ └── useTagsForm.ts │ ├── torrent │ │ ├── card │ │ │ ├── index.ts │ │ │ ├── fileInfo.tsx │ │ │ ├── __tests__ │ │ │ │ └── card.test.tsx │ │ │ ├── connectionInfo.tsx │ │ │ ├── progressIndicator.tsx │ │ │ ├── titleAndMenu.tsx │ │ │ ├── card.tsx │ │ │ └── statusInfo.tsx │ │ ├── index.ts │ │ ├── state │ │ │ ├── index.ts │ │ │ └── selectors.ts │ │ ├── modals │ │ │ ├── index.ts │ │ │ ├── useDeleteTorrentsModal.tsx │ │ │ ├── useTorrentCategoryModal.tsx │ │ │ └── useTorrentTagsModal.tsx │ │ ├── useAddTorrentForm.ts │ │ └── useContextMenuItems.tsx │ ├── categories │ │ ├── index.ts │ │ ├── state │ │ │ ├── index.ts │ │ │ ├── selectors.ts │ │ │ ├── reducer.ts │ │ │ └── saga.ts │ │ ├── modals │ │ │ ├── index.ts │ │ │ ├── useCreateCategoryModal.tsx │ │ │ ├── useEditCategoryModal.tsx │ │ │ └── useCategoriesModal.tsx │ │ └── useCategoryForm.ts │ ├── preferences │ │ ├── index.ts │ │ ├── modals │ │ │ ├── index.ts │ │ │ ├── sections │ │ │ │ ├── speed │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── globalRateLimitsSection.tsx │ │ │ │ │ ├── rateLimitsSettingsSection.tsx │ │ │ │ │ └── speedSection.tsx │ │ │ │ ├── webui │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── securitySection.tsx │ │ │ │ │ └── authenticationSection.tsx │ │ │ │ ├── bitTorrent │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── privacySection.tsx │ │ │ │ │ ├── bitTorrentSection.tsx │ │ │ │ │ └── seedingLimitsSection.tsx │ │ │ │ ├── connection │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── listeningPortSection.tsx │ │ │ │ │ ├── ipFilteringSection.tsx │ │ │ │ │ ├── connectionLimitsSection.tsx │ │ │ │ │ └── connectionSection.tsx │ │ │ │ ├── downloads │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── addingTorrentSection.tsx │ │ │ │ │ └── downloadsSection.tsx │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ └── usePreferencesModal.tsx │ │ └── state │ │ │ ├── index.ts │ │ │ ├── selectors.ts │ │ │ ├── reducer.ts │ │ │ └── saga.ts │ ├── settings │ │ ├── index.ts │ │ ├── modals │ │ │ ├── index.ts │ │ │ ├── useSettings.ts │ │ │ └── useSettingsModal.tsx │ │ └── state │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ ├── selectors.ts │ │ │ ├── actions.ts │ │ │ └── reducer.ts │ ├── resource │ │ ├── baseAction.ts │ │ ├── index.ts │ │ ├── createSetResourceSaga.ts │ │ ├── createFetchResourceSaga.ts │ │ ├── createDeleteResourceSaga.ts │ │ └── types.ts │ ├── torrentProperties │ │ ├── index.ts │ │ ├── modals │ │ │ ├── index.ts │ │ │ ├── tabs │ │ │ │ ├── index.ts │ │ │ │ ├── generalTab.tsx │ │ │ │ ├── transferTab.tsx │ │ │ │ └── contentsTab.tsx │ │ │ └── useTorrentPropertiesModal.tsx │ │ └── state │ │ │ ├── index.ts │ │ │ ├── selectors.ts │ │ │ ├── reducer.ts │ │ │ └── saga.ts │ ├── mock │ │ ├── tags.ts │ │ ├── isMockEnabled.ts │ │ ├── index.ts │ │ ├── categories.ts │ │ ├── mainData.ts │ │ ├── preferences.ts │ │ └── transferInfo.ts │ ├── mainData │ │ ├── modals │ │ │ ├── index.ts │ │ │ └── useServerStateModal.tsx │ │ ├── index.ts │ │ ├── state │ │ │ ├── index.ts │ │ │ ├── selectors.ts │ │ │ ├── reducer.ts │ │ │ └── saga.ts │ │ └── hooks.ts │ ├── models │ │ ├── connectionSettings.ts │ │ ├── category.ts │ │ ├── session.ts │ │ ├── torrentFilters.ts │ │ ├── settings.ts │ │ ├── index.ts │ │ ├── peer.ts │ │ ├── mainData.ts │ │ ├── torrentContent.ts │ │ ├── torrentTracker.ts │ │ ├── sync.ts │ │ ├── torrentProperties.ts │ │ └── transferInfo.ts │ ├── navigation │ │ ├── types.ts │ │ ├── history.ts │ │ ├── index.ts │ │ └── router.tsx │ ├── transferInfo │ │ ├── index.ts │ │ ├── state │ │ │ ├── index.ts │ │ │ ├── selectors.ts │ │ │ ├── reducer.ts │ │ │ └── saga.ts │ │ └── card.tsx │ ├── snackbar │ │ ├── index.ts │ │ ├── actions.ts │ │ └── saga.tsx │ ├── localStorage │ │ ├── types.ts │ │ ├── index.ts │ │ ├── useLocalStorage.ts │ │ └── localStorage.ts │ ├── i18n │ │ ├── index.ts │ │ ├── types.ts │ │ ├── i18nProvider.tsx │ │ └── languagePicker.tsx │ ├── generic │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ ├── scrollToTopAffix.test.tsx.snap │ │ │ │ ├── labelWithText.test.tsx.snap │ │ │ │ └── labelWithBadge.test.tsx.snap │ │ │ ├── labelWithText.test.tsx │ │ │ ├── labelWithBadge.test.tsx │ │ │ └── scrollToTopAffix.test.tsx │ │ ├── useIsSmallDevice.ts │ │ ├── commonConfigs.ts │ │ ├── useWindowSize.ts │ │ ├── index.ts │ │ ├── contextMenu.tsx │ │ ├── page.tsx │ │ ├── labelWithText.tsx │ │ ├── scrollToTopAffix.tsx │ │ ├── labelWithBadge.tsx │ │ ├── drawerPage.tsx │ │ ├── colorSchemeToggle.tsx │ │ └── dropzone.tsx │ ├── hooks │ │ ├── useIsLoggedIn.ts │ │ ├── index.ts │ │ ├── useLogout.ts │ │ ├── useLogin.ts │ │ ├── useCloseLastModal.ts │ │ ├── useCreateResource.ts │ │ ├── useFetchResource.ts │ │ ├── useDeleteResource.ts │ │ └── useToggleTheme.ts │ ├── importMetaUtils.ts │ ├── ThemeProvider.tsx │ ├── utils.ts │ └── useAboutModal.tsx ├── vite-env.d.ts ├── locales │ ├── en │ │ └── messages.d.ts │ └── ro │ │ └── messages.d.ts ├── main.tsx ├── index.css └── App.tsx ├── public ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png └── vite.svg ├── configs ├── mock.sh ├── release.sh └── debug.sh ├── .linguirc ├── tsconfig.node.json ├── .babelrc ├── .gitignore ├── setupTests.ts ├── index.html ├── vite.config.ts ├── .prettierrc ├── deploy.sh ├── tsconfig.json ├── .circleci └── config.yml ├── .eslintrc └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | *.test.* -------------------------------------------------------------------------------- /src/meridian/icons/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | -------------------------------------------------------------------------------- /src/meridian/session/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state'; 2 | -------------------------------------------------------------------------------- /src/meridian/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from './store'; 2 | -------------------------------------------------------------------------------- /src/meridian/testing/index.ts: -------------------------------------------------------------------------------- 1 | export * from './helpers'; 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/meridian/torrentContent/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state'; 2 | -------------------------------------------------------------------------------- /src/meridian/torrentFilters/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state'; 2 | -------------------------------------------------------------------------------- /src/meridian/torrentTrackers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state'; 2 | -------------------------------------------------------------------------------- /src/meridian/transformers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mainDataTransformer'; 2 | -------------------------------------------------------------------------------- /src/meridian/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api'; 2 | export * from './types'; 3 | -------------------------------------------------------------------------------- /src/meridian/home/index.ts: -------------------------------------------------------------------------------- 1 | export { default as HomePage } from './homePage'; 2 | -------------------------------------------------------------------------------- /src/meridian/login/index.ts: -------------------------------------------------------------------------------- 1 | export { default as LoginPage } from './loginPage'; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gabi1M/QBitUI/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/meridian/tags/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state'; 2 | export * from './modals'; 3 | -------------------------------------------------------------------------------- /src/meridian/torrent/card/index.ts: -------------------------------------------------------------------------------- 1 | export { default as TorrentCard } from './card'; 2 | -------------------------------------------------------------------------------- /src/meridian/categories/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state'; 2 | export * from './modals'; 3 | -------------------------------------------------------------------------------- /src/meridian/preferences/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state'; 2 | export * from './modals'; 3 | -------------------------------------------------------------------------------- /src/meridian/settings/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state'; 2 | export * from './modals'; 3 | -------------------------------------------------------------------------------- /src/meridian/state/sagas/index.ts: -------------------------------------------------------------------------------- 1 | export { default as startupSaga } from './startupSaga'; 2 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gabi1M/QBitUI/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gabi1M/QBitUI/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /src/meridian/resource/baseAction.ts: -------------------------------------------------------------------------------- 1 | export interface BaseAction { 2 | type: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/meridian/torrentProperties/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state'; 2 | export * from './modals'; 3 | -------------------------------------------------------------------------------- /configs/mock.sh: -------------------------------------------------------------------------------- 1 | export VITE_MOCK_ENABLED="true" 2 | export VITE_API_URL="" 3 | export DEPLOY_PATH="" -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gabi1M/QBitUI/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /src/meridian/icons/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gabi1M/QBitUI/HEAD/src/meridian/icons/logo.png -------------------------------------------------------------------------------- /src/meridian/mock/tags.ts: -------------------------------------------------------------------------------- 1 | export const MockTags: string[] = ['Tag 1', 'Tag 2', 'Tag 3', 'Tag 4']; 2 | -------------------------------------------------------------------------------- /src/meridian/settings/modals/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useSettingsModal } from './useSettingsModal'; 2 | -------------------------------------------------------------------------------- /configs/release.sh: -------------------------------------------------------------------------------- 1 | export VITE_MOCK_ENABLED="false" 2 | export VITE_API_URL="" 3 | export DEPLOY_PATH="./release" -------------------------------------------------------------------------------- /src/meridian/icons/romania.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gabi1M/QBitUI/HEAD/src/meridian/icons/romania.png -------------------------------------------------------------------------------- /src/meridian/mainData/modals/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useServerStateModal } from './useServerStateModal'; 2 | -------------------------------------------------------------------------------- /src/meridian/models/connectionSettings.ts: -------------------------------------------------------------------------------- 1 | export interface ConnectionSettings { 2 | url: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/meridian/navigation/types.ts: -------------------------------------------------------------------------------- 1 | export enum AppRoutes { 2 | HOME = '/', 3 | LOGIN = 'login', 4 | } 5 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gabi1M/QBitUI/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gabi1M/QBitUI/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/meridian/mainData/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state'; 2 | export * from './hooks'; 3 | export * from './modals'; 4 | -------------------------------------------------------------------------------- /src/meridian/models/category.ts: -------------------------------------------------------------------------------- 1 | export interface Category { 2 | name: string; 3 | savePath: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/index.ts: -------------------------------------------------------------------------------- 1 | export { default as usePreferencesModal } from './usePreferencesModal'; 2 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/speed/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SpeedSection } from './speedSection'; 2 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/webui/index.ts: -------------------------------------------------------------------------------- 1 | export { default as WebUiSection } from './webUiSection'; 2 | -------------------------------------------------------------------------------- /src/meridian/icons/united-states.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gabi1M/QBitUI/HEAD/src/meridian/icons/united-states.png -------------------------------------------------------------------------------- /src/meridian/transferInfo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state'; 2 | export { default as TransferInfoCard } from './card'; 3 | -------------------------------------------------------------------------------- /configs/debug.sh: -------------------------------------------------------------------------------- 1 | export VITE_MOCK_ENABLED="false" 2 | export VITE_API_URL="http://192.168.0.189:8080" 3 | export DEPLOY_PATH="" -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/bitTorrent/index.ts: -------------------------------------------------------------------------------- 1 | export { default as BitTorrentSection } from './bitTorrentSection'; 2 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/connection/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ConnectionSection } from './connectionSection'; 2 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/downloads/index.ts: -------------------------------------------------------------------------------- 1 | export { default as DownloadsSection } from './downloadsSection'; 2 | -------------------------------------------------------------------------------- /src/meridian/settings/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions'; 2 | export * from './reducer'; 3 | export * from './selectors'; 4 | -------------------------------------------------------------------------------- /src/meridian/snackbar/index.ts: -------------------------------------------------------------------------------- 1 | export { default as snackbarSaga } from './saga'; 2 | export { showSnackbarAction } from './actions'; 3 | -------------------------------------------------------------------------------- /src/meridian/torrentFilters/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions'; 2 | export * from './reducer'; 3 | export * from './selectors'; 4 | -------------------------------------------------------------------------------- /src/meridian/torrentProperties/modals/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useTorrentPropertiesModal } from './useTorrentPropertiesModal'; 2 | -------------------------------------------------------------------------------- /src/locales/en/messages.d.ts: -------------------------------------------------------------------------------- 1 | import { Messages } from '@lingui/core'; 2 | 3 | declare const messages: Messages; 4 | export { messages }; 5 | -------------------------------------------------------------------------------- /src/locales/ro/messages.d.ts: -------------------------------------------------------------------------------- 1 | import { Messages } from '@lingui/core'; 2 | 3 | declare const messages: Messages; 4 | export { messages }; 5 | -------------------------------------------------------------------------------- /src/meridian/navigation/history.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | 3 | export const history = createBrowserHistory(); 4 | -------------------------------------------------------------------------------- /src/meridian/navigation/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export { default as AppRouter } from './router'; 3 | export * from './history'; 4 | -------------------------------------------------------------------------------- /src/meridian/tags/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from './reducer'; 2 | export * from './selectors'; 3 | export { default as tagsSaga } from './saga'; 4 | -------------------------------------------------------------------------------- /src/meridian/torrent/index.ts: -------------------------------------------------------------------------------- 1 | export * from './card'; 2 | export * from './modals'; 3 | export * from './state'; 4 | export * from './hooks'; 5 | -------------------------------------------------------------------------------- /src/meridian/localStorage/types.ts: -------------------------------------------------------------------------------- 1 | export enum LocalStorageKey { 2 | SETTINGS = 'settings', 3 | TORRENT_FILTERS = 'torrent_filters', 4 | } 5 | -------------------------------------------------------------------------------- /src/meridian/torrent/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from './reducer'; 2 | export * from './selectors'; 3 | export { default as torrentSaga } from './saga'; 4 | -------------------------------------------------------------------------------- /src/meridian/categories/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from './reducer'; 2 | export * from './selectors'; 3 | export { default as categoriesSaga } from './saga'; 4 | -------------------------------------------------------------------------------- /src/meridian/mainData/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from './reducer'; 2 | export * from './selectors'; 3 | export { default as mainDataSaga } from './saga'; 4 | -------------------------------------------------------------------------------- /src/meridian/mock/isMockEnabled.ts: -------------------------------------------------------------------------------- 1 | import { getIsMockEnabled } from 'meridian/importMetaUtils'; 2 | 3 | export const isMockEnabled = getIsMockEnabled(); 4 | -------------------------------------------------------------------------------- /src/meridian/session/state/types.ts: -------------------------------------------------------------------------------- 1 | export interface SessionState { 2 | loggedIn: boolean; 3 | version: string; 4 | apiVersion: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/meridian/preferences/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from './reducer'; 2 | export * from './selectors'; 3 | export { default as preferencesSaga } from './saga'; 4 | -------------------------------------------------------------------------------- /src/meridian/settings/state/types.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from 'meridian/models'; 2 | 3 | export interface SettingsState { 4 | settings: Settings; 5 | } 6 | -------------------------------------------------------------------------------- /src/meridian/transferInfo/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from './reducer'; 2 | export * from './selectors'; 3 | export { default as transferInfoSaga } from './saga'; 4 | -------------------------------------------------------------------------------- /src/meridian/tags/modals/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useCreateTagModal } from './useCreateTagModal'; 2 | export { default as useTagsModal } from './useTagsModal'; 3 | -------------------------------------------------------------------------------- /src/meridian/torrentContent/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from './reducer'; 2 | export * from './selectors'; 3 | export { default as torrentContentSaga } from './saga'; 4 | -------------------------------------------------------------------------------- /src/meridian/torrentTrackers/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from './reducer'; 2 | export * from './selectors'; 3 | export { default as torrentTrackersSaga } from './saga'; 4 | -------------------------------------------------------------------------------- /src/meridian/torrentProperties/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from './reducer'; 2 | export * from './selectors'; 3 | export { default as torrentPropertiesSaga } from './saga'; 4 | -------------------------------------------------------------------------------- /src/meridian/i18n/index.ts: -------------------------------------------------------------------------------- 1 | export { default as I18nProvider } from './i18nProvider'; 2 | export { default as LanguagePicker } from './languagePicker'; 3 | export * from './types'; 4 | -------------------------------------------------------------------------------- /src/meridian/session/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions'; 2 | export * from './reducer'; 3 | export * from './selectors'; 4 | export { default as sessionSaga } from './saga'; 5 | -------------------------------------------------------------------------------- /src/meridian/torrentFilters/state/types.ts: -------------------------------------------------------------------------------- 1 | import { TorrentFilters } from 'meridian/models'; 2 | 3 | export interface TorrentFiltersState { 4 | filters: TorrentFilters; 5 | } 6 | -------------------------------------------------------------------------------- /src/meridian/localStorage/index.ts: -------------------------------------------------------------------------------- 1 | export { default as LocalStorage } from './localStorage'; 2 | export { default as useLocalStorage } from './useLocalStorage'; 3 | export * from './types'; 4 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/index.ts: -------------------------------------------------------------------------------- 1 | export * from './connection'; 2 | export * from './speed'; 3 | export * from './downloads'; 4 | export * from './bitTorrent'; 5 | export * from './webui'; 6 | -------------------------------------------------------------------------------- /.linguirc: -------------------------------------------------------------------------------- 1 | { 2 | "locales": ["en", "ro"], 3 | "sourceLocale": "en", 4 | "catalogs": [{ 5 | "path": "src/locales/{locale}/messages", 6 | "include": ["src"] 7 | }], 8 | "format": "po" 9 | } -------------------------------------------------------------------------------- /src/meridian/generic/__tests__/__snapshots__/scrollToTopAffix.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ScrollToTopAffix should render as expected 1`] = ``; 4 | -------------------------------------------------------------------------------- /src/meridian/mainData/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useFetchResource } from 'meridian/hooks'; 2 | import { Resource } from 'meridian/resource'; 3 | 4 | export const useRefreshMainData = () => useFetchResource(Resource.MAIN_DATA); 5 | -------------------------------------------------------------------------------- /src/meridian/models/session.ts: -------------------------------------------------------------------------------- 1 | export interface LoginData { 2 | username: string; 3 | password: string; 4 | } 5 | 6 | export enum LoginResponse { 7 | SUCCESS = 'Ok.', 8 | FAIL = 'Fails.', 9 | } 10 | -------------------------------------------------------------------------------- /src/meridian/hooks/useIsLoggedIn.ts: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | 3 | import { selectSessionIsLoggedIn } from 'meridian/session'; 4 | 5 | export const useIsLoggedIn = () => useSelector(selectSessionIsLoggedIn); 6 | -------------------------------------------------------------------------------- /src/meridian/tags/state/selectors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line no-restricted-imports */ 2 | import { GlobalState } from 'meridian/state/types'; 3 | 4 | export const selectTags = (state: GlobalState) => state.tagsState.fetch.data; 5 | -------------------------------------------------------------------------------- /src/meridian/mainData/state/selectors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line no-restricted-imports */ 2 | import { GlobalState } from 'meridian/state/types'; 3 | 4 | export const selectMainData = (state: GlobalState) => state.mainDataState.fetch.data; 5 | -------------------------------------------------------------------------------- /src/meridian/settings/state/selectors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line no-restricted-imports */ 2 | import { GlobalState } from 'meridian/state/types'; 3 | 4 | export const selectSettings = (state: GlobalState) => state.settingsState.settings; 5 | -------------------------------------------------------------------------------- /src/meridian/tags/state/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Resource, createResourceReducer } from 'meridian/resource'; 2 | 3 | const { reducer, actions } = createResourceReducer(Resource.TAGS); 4 | export { reducer as tagsReducer, actions as TagsActions }; 5 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/meridian/generic/useIsSmallDevice.ts: -------------------------------------------------------------------------------- 1 | import useWindowSize from './useWindowSize'; 2 | 3 | const useIsSmallDevice = () => { 4 | const { width } = useWindowSize(); 5 | return width < 450; 6 | }; 7 | 8 | export default useIsSmallDevice; 9 | -------------------------------------------------------------------------------- /src/meridian/categories/state/selectors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line no-restricted-imports */ 2 | import { GlobalState } from 'meridian/state/types'; 3 | 4 | export const selectCategories = (state: GlobalState) => state.categoriesState.fetch.data; 5 | -------------------------------------------------------------------------------- /src/meridian/preferences/state/selectors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line no-restricted-imports */ 2 | import { GlobalState } from 'meridian/state/types'; 3 | 4 | export const selectPreferences = (state: GlobalState) => state.preferencesState.fetch.data; 5 | -------------------------------------------------------------------------------- /src/meridian/generic/commonConfigs.ts: -------------------------------------------------------------------------------- 1 | import { ModalProps } from '@mantine/core'; 2 | 3 | export const commonModalConfiguration: Partial = { 4 | centered: true, 5 | overlayBlur: 5, 6 | overflow: 'inside', 7 | radius: 'lg', 8 | }; 9 | -------------------------------------------------------------------------------- /src/meridian/mainData/state/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Resource, createResourceReducer } from 'meridian/resource'; 2 | 3 | const { reducer, actions } = createResourceReducer(Resource.MAIN_DATA); 4 | export { reducer as mainDataReducer, actions as MainDataActions }; 5 | -------------------------------------------------------------------------------- /src/meridian/mock/index.ts: -------------------------------------------------------------------------------- 1 | export * from './categories'; 2 | export * from './tags'; 3 | export * from './torrents'; 4 | export * from './transferInfo'; 5 | export * from './mainData'; 6 | export * from './preferences'; 7 | export * from './isMockEnabled'; 8 | -------------------------------------------------------------------------------- /src/meridian/models/torrentFilters.ts: -------------------------------------------------------------------------------- 1 | import { TorrentStateDescription } from './torrent'; 2 | 3 | export interface TorrentFilters { 4 | name: string; 5 | states: TorrentStateDescription[]; 6 | categories: string[]; 7 | tags: string[]; 8 | } 9 | -------------------------------------------------------------------------------- /src/meridian/torrentFilters/state/selectors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line no-restricted-imports */ 2 | import { GlobalState } from 'meridian/state/types'; 3 | 4 | export const selectTorrentFilters = (state: GlobalState) => state.torrentFiltersState.filters; 5 | -------------------------------------------------------------------------------- /src/meridian/transferInfo/state/selectors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line no-restricted-imports */ 2 | import { GlobalState } from 'meridian/state/types'; 3 | 4 | export const selectTransferInfo = (state: GlobalState) => state.transferInfoState.fetch.data; 5 | -------------------------------------------------------------------------------- /src/meridian/torrentContent/state/selectors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line no-restricted-imports */ 2 | import { GlobalState } from 'meridian/state/types'; 3 | 4 | export const selectTorrentContent = (state: GlobalState) => state.torrentContentState.fetch.data; 5 | -------------------------------------------------------------------------------- /src/meridian/categories/modals/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useCreateCategoryModal } from './useCreateCategoryModal'; 2 | export { default as useEditCategoryModal } from './useEditCategoryModal'; 3 | export { default as useCategoriesModal } from './useCategoriesModal'; 4 | -------------------------------------------------------------------------------- /src/meridian/categories/state/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Resource, createResourceReducer } from 'meridian/resource'; 2 | 3 | const { reducer, actions } = createResourceReducer(Resource.CATEGORIES); 4 | export { reducer as categoriesReducer, actions as CategoriesActions }; 5 | -------------------------------------------------------------------------------- /src/meridian/torrentTrackers/state/selectors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line no-restricted-imports */ 2 | import { GlobalState } from 'meridian/state/types'; 3 | 4 | export const selectTorrentTrackers = (state: GlobalState) => state.torrentTrackersState.fetch.data; 5 | -------------------------------------------------------------------------------- /src/meridian/preferences/state/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Resource, createResourceReducer } from 'meridian/resource'; 2 | 3 | const { reducer, actions } = createResourceReducer(Resource.PREFERENCES); 4 | export { reducer as preferencesReducer, actions as PreferencesActions }; 5 | -------------------------------------------------------------------------------- /src/meridian/resource/index.ts: -------------------------------------------------------------------------------- 1 | export * from './baseAction'; 2 | export * from './types'; 3 | export * from './createResourceReducer'; 4 | export * from './createFetchResourceSaga'; 5 | export * from './createSetResourceSaga'; 6 | export * from './createDeleteResourceSaga'; 7 | -------------------------------------------------------------------------------- /src/meridian/torrentProperties/state/selectors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line no-restricted-imports */ 2 | import { GlobalState } from 'meridian/state/types'; 3 | 4 | export const selectTorrentProperties = (state: GlobalState) => 5 | state.torrentPropertiesState.fetch.data; 6 | -------------------------------------------------------------------------------- /src/meridian/transferInfo/state/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Resource, createResourceReducer } from 'meridian/resource'; 2 | 3 | const { reducer, actions } = createResourceReducer(Resource.TRANSFER_INFO); 4 | export { reducer as transferInfoReducer, actions as TransferInfoActions }; 5 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | [ 5 | "@babel/preset-react", 6 | { 7 | "runtime": "automatic", 8 | "importSource": "react" 9 | } 10 | ] 11 | ], 12 | "plugins": [ 13 | "macros" 14 | ] 15 | } -------------------------------------------------------------------------------- /src/meridian/torrentContent/state/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Resource, createResourceReducer } from 'meridian/resource'; 2 | 3 | const { reducer, actions } = createResourceReducer(Resource.TORRENT_CONTENT); 4 | export { reducer as torrentContentReducer, actions as TorrentContentActions }; 5 | -------------------------------------------------------------------------------- /src/meridian/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useFetchResource'; 2 | export * from './useCreateResource'; 3 | export * from './useDeleteResource'; 4 | export * from './useToggleTheme'; 5 | export * from './useLogin'; 6 | export * from './useLogout'; 7 | export * from './useCloseLastModal'; 8 | -------------------------------------------------------------------------------- /src/meridian/torrentProperties/modals/tabs/index.ts: -------------------------------------------------------------------------------- 1 | export { default as GeneralTab } from './generalTab'; 2 | export { default as TransferTab } from './transferTab'; 3 | export { default as ContentsTab } from './contentsTab'; 4 | export { default as TrackersTab } from './trackersTab'; 5 | -------------------------------------------------------------------------------- /src/meridian/torrentTrackers/state/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Resource, createResourceReducer } from 'meridian/resource'; 2 | 3 | const { reducer, actions } = createResourceReducer(Resource.TORRENT_TRACKERS); 4 | export { reducer as torrentTrackersReducer, actions as TorrentTrackersActions }; 5 | -------------------------------------------------------------------------------- /src/meridian/icons/types.ts: -------------------------------------------------------------------------------- 1 | import logo from './logo.png'; 2 | import romania from './romania.png'; 3 | import unitedStates from './united-states.png'; 4 | 5 | export const Icons = Object.freeze({ 6 | ROMANIA: romania, 7 | UNITED_STATES: unitedStates, 8 | LOGO: logo, 9 | }); 10 | -------------------------------------------------------------------------------- /src/meridian/torrentProperties/state/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Resource, createResourceReducer } from 'meridian/resource'; 2 | 3 | const { reducer, actions } = createResourceReducer(Resource.TORRENT_PROPERTIES); 4 | export { reducer as torrentPropertiesReducer, actions as TorrentPropertiesActions }; 5 | -------------------------------------------------------------------------------- /src/meridian/importMetaUtils.ts: -------------------------------------------------------------------------------- 1 | export const getVersion = () => import.meta.env.VITE_VERSION; 2 | export const getIsDevEnv = () => import.meta.env.DEV; 3 | export const getIsMockEnabled = () => import.meta.env.VITE_MOCK_ENABLED === 'true'; 4 | export const getApiUrl = () => import.meta.env.VITE_API_URL; 5 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import App from './App'; 5 | import './index.css'; 6 | 7 | createRoot(document.getElementById('root') as HTMLElement).render( 8 | 9 | 10 | , 11 | ); 12 | -------------------------------------------------------------------------------- /src/meridian/torrent/state/selectors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line no-restricted-imports */ 2 | import { GlobalState } from 'meridian/state/types'; 3 | 4 | export const selectTorrents = (state: GlobalState) => state.torrentState.fetch.data; 5 | export const selectTorrentError = (state: GlobalState) => state.torrentState.fetch.error; 6 | -------------------------------------------------------------------------------- /src/meridian/models/settings.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line no-restricted-imports */ 2 | import { Language } from 'meridian/i18n/types'; 3 | 4 | export interface Settings { 5 | darkMode: boolean; 6 | autoRefresh: boolean; 7 | autoRefreshInterval: number; 8 | torrentsPerPage: number; 9 | language: Language; 10 | } 11 | -------------------------------------------------------------------------------- /src/meridian/torrent/modals/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useAddTorrentsModal } from './useAddTorrentsModal'; 2 | export { default as useDeleteTorrentsModal } from './useDeleteTorrentsModal'; 3 | export { default as useTorrentCategoryModal } from './useTorrentCategoryModal'; 4 | export { default as useTorrentTagsModal } from './useTorrentTagsModal'; 5 | -------------------------------------------------------------------------------- /src/meridian/login/useNavigateToLogin.ts: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | 4 | import { AppRoutes } from 'meridian/navigation'; 5 | 6 | export const useNavigateToLogin = () => { 7 | const navigate = useNavigate(); 8 | return useCallback(() => navigate(AppRoutes.LOGIN), [navigate]); 9 | }; 10 | -------------------------------------------------------------------------------- /src/meridian/home/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useFetchTimer } from './useFetchTimer'; 2 | export { default as useFilteredTorrents } from './useFilteredTorrents'; 3 | export { default as useManageSelection } from './useManageSelection'; 4 | export { default as usePagination } from './usePagination'; 5 | export { default as useHeaderMenuItems } from './useHeaderMenuItems'; 6 | -------------------------------------------------------------------------------- /src/meridian/hooks/useLogout.ts: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | 4 | import { logoutAction } from 'meridian/session'; 5 | 6 | export const useLogout = () => { 7 | const dispatch = useDispatch(); 8 | 9 | return useCallback(() => { 10 | dispatch(logoutAction()); 11 | }, [dispatch]); 12 | }; 13 | -------------------------------------------------------------------------------- /src/meridian/mainData/state/saga.ts: -------------------------------------------------------------------------------- 1 | import { takeLatest } from 'redux-saga/effects'; 2 | 3 | import { Resource, createFetchResourceSaga } from 'meridian/resource'; 4 | 5 | import { MainDataActions } from './reducer'; 6 | 7 | function* mainDataSaga() { 8 | yield takeLatest(MainDataActions.FETCH, createFetchResourceSaga(Resource.MAIN_DATA)); 9 | } 10 | 11 | export default mainDataSaga; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | coverage 12 | dist 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | release -------------------------------------------------------------------------------- /src/meridian/session/state/selectors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line no-restricted-imports */ 2 | import { GlobalState } from 'meridian/state/types'; 3 | 4 | export const selectSessionIsLoggedIn = (state: GlobalState) => state.sessionState.loggedIn; 5 | 6 | export const selectVersions = (state: GlobalState) => ({ 7 | version: state.sessionState.version, 8 | apiVersion: state.sessionState.apiVersion, 9 | }); 10 | -------------------------------------------------------------------------------- /src/meridian/transferInfo/state/saga.ts: -------------------------------------------------------------------------------- 1 | import { takeLatest } from 'redux-saga/effects'; 2 | 3 | import { Resource, createFetchResourceSaga } from 'meridian/resource'; 4 | 5 | import { TransferInfoActions } from './reducer'; 6 | 7 | function* transferInfoSaga() { 8 | yield takeLatest(TransferInfoActions.FETCH, createFetchResourceSaga(Resource.TRANSFER_INFO)); 9 | } 10 | 11 | export default transferInfoSaga; 12 | -------------------------------------------------------------------------------- /src/meridian/transformers/mainDataTransformer.ts: -------------------------------------------------------------------------------- 1 | import { MainData, TorrentInfo } from 'meridian/models'; 2 | 3 | export const transformMainData = (mainData: MainData) => { 4 | mainData.torrents = Object.entries(mainData.torrents).reduce( 5 | (result, [hash, torrent]) => ((result[hash] = { ...torrent, hash }), result), 6 | {} as Record, 7 | ); 8 | return mainData; 9 | }; 10 | -------------------------------------------------------------------------------- /src/meridian/localStorage/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import LocalStorage from './localStorage'; 2 | import { LocalStorageKey } from './types'; 3 | 4 | const useLocalStorage = () => { 5 | const setValue = (key: LocalStorageKey, value: T) => LocalStorage.setValue(key, value); 6 | const getValue = (key: LocalStorageKey) => LocalStorage.getValue(key); 7 | return { setValue, getValue }; 8 | }; 9 | 10 | export default useLocalStorage; 11 | -------------------------------------------------------------------------------- /src/meridian/generic/__tests__/labelWithText.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import LabelWithText from '../labelWithText'; 6 | 7 | describe('LabelWithText', () => { 8 | it('should render as expected', () => { 9 | const result = render(); 10 | expect(result.asFragment()).toMatchSnapshot(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/meridian/hooks/useLogin.ts: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | 4 | import { loginAction } from 'meridian/session'; 5 | 6 | export const useLogin = () => { 7 | const dispatch = useDispatch(); 8 | 9 | return useCallback( 10 | (username: string, password: string) => { 11 | dispatch(loginAction(username, password)); 12 | }, 13 | [dispatch], 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/meridian/mock/categories.ts: -------------------------------------------------------------------------------- 1 | import { Category } from 'meridian/models'; 2 | 3 | export const MockCategories: Record = { 4 | Category1: { 5 | name: 'Category 1', 6 | savePath: 'Save path 1', 7 | }, 8 | 'Category 2': { 9 | name: 'Category 2', 10 | savePath: 'Save path 2', 11 | }, 12 | 'Category 3': { 13 | name: 'Category 3', 14 | savePath: 'Save path 3', 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/meridian/torrentContent/state/saga.ts: -------------------------------------------------------------------------------- 1 | import { takeLatest } from 'redux-saga/effects'; 2 | 3 | import { Resource, createFetchResourceSaga } from 'meridian/resource'; 4 | 5 | import { TorrentContentActions } from './reducer'; 6 | 7 | function* torrentContentSaga() { 8 | yield takeLatest( 9 | TorrentContentActions.FETCH, 10 | createFetchResourceSaga(Resource.TORRENT_CONTENT), 11 | ); 12 | } 13 | 14 | export default torrentContentSaga; 15 | -------------------------------------------------------------------------------- /src/meridian/i18n/types.ts: -------------------------------------------------------------------------------- 1 | import { t } from '@lingui/macro'; 2 | 3 | import { Icons } from 'meridian/icons'; 4 | 5 | export enum Language { 6 | ENGLISH = 'en', 7 | ROMANIAN = 'ro', 8 | } 9 | 10 | export const LanguageName = { 11 | [Language.ENGLISH]: t`English`, 12 | [Language.ROMANIAN]: t`Romanian`, 13 | }; 14 | 15 | export const LanguageIcon = { 16 | [Language.ENGLISH]: Icons.UNITED_STATES, 17 | [Language.ROMANIAN]: Icons.ROMANIA, 18 | }; 19 | -------------------------------------------------------------------------------- /src/meridian/torrentTrackers/state/saga.ts: -------------------------------------------------------------------------------- 1 | import { takeLatest } from 'redux-saga/effects'; 2 | 3 | import { Resource, createFetchResourceSaga } from 'meridian/resource'; 4 | 5 | import { TorrentTrackersActions } from './reducer'; 6 | 7 | function* torrentTrackersSaga() { 8 | yield takeLatest( 9 | TorrentTrackersActions.FETCH, 10 | createFetchResourceSaga(Resource.TORRENT_TRACKERS), 11 | ); 12 | } 13 | 14 | export default torrentTrackersSaga; 15 | -------------------------------------------------------------------------------- /src/meridian/generic/__tests__/labelWithBadge.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import LabelWithBadge from '../labelWithBadge'; 6 | 7 | describe('LabelWithBadge', () => { 8 | it('should render as expected', () => { 9 | const result = render(); 10 | expect(result.asFragment()).toMatchSnapshot(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/meridian/settings/state/actions.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from 'meridian/models'; 2 | import { BaseAction } from 'meridian/resource'; 3 | 4 | export enum SettingsActions { 5 | SET_SETTINGS = 'SET_SETTINGS', 6 | } 7 | 8 | export interface SetSettingsAction extends BaseAction { 9 | settings: Settings; 10 | } 11 | export const createSetSettingsAction = (settings: Settings): SetSettingsAction => ({ 12 | type: SettingsActions.SET_SETTINGS, 13 | settings, 14 | }); 15 | -------------------------------------------------------------------------------- /src/meridian/localStorage/localStorage.ts: -------------------------------------------------------------------------------- 1 | import { LocalStorageKey } from './types'; 2 | 3 | class LocalStorage { 4 | static setValue = (key: LocalStorageKey, value: T) => { 5 | localStorage.setItem(key, JSON.stringify(value)); 6 | }; 7 | 8 | static getValue = (key: LocalStorageKey) => { 9 | const value = localStorage.getItem(key); 10 | return value ? (JSON.parse(value) as T) : null; 11 | }; 12 | } 13 | 14 | export default LocalStorage; 15 | -------------------------------------------------------------------------------- /src/meridian/torrentProperties/state/saga.ts: -------------------------------------------------------------------------------- 1 | import { takeLatest } from 'redux-saga/effects'; 2 | 3 | import { Resource, createFetchResourceSaga } from 'meridian/resource'; 4 | 5 | import { TorrentPropertiesActions } from './reducer'; 6 | 7 | function* torrentPropertiesSaga() { 8 | yield takeLatest( 9 | TorrentPropertiesActions.FETCH, 10 | createFetchResourceSaga(Resource.TORRENT_PROPERTIES), 11 | ); 12 | } 13 | 14 | export default torrentPropertiesSaga; 15 | -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | import fetchMock from 'jest-fetch-mock'; 2 | 3 | fetchMock.enableMocks(); 4 | 5 | jest.mock('@lingui/macro', () => { 6 | return { 7 | ...jest.requireActual('@lingui/macro'), 8 | t: jest.fn(), 9 | }; 10 | }); 11 | 12 | jest.mock('src/meridian/importMetaUtils', () => ({ 13 | getVersion: jest.fn(), 14 | getIsDevEnv: jest.fn(), 15 | getIsMockEnabled: jest.fn(), 16 | getApiUrl: jest.fn(() => 'http://127.0.0.1:1000'), 17 | })); 18 | 19 | export { } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | QBitUI 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/meridian/tags/useTagsForm.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { useForm } from '@mantine/form'; 6 | 7 | const useTagForm = () => { 8 | const initialValues = { 9 | tagName: '', 10 | }; 11 | 12 | return useForm({ 13 | initialValues, 14 | validate: { 15 | tagName: (value) => (value !== '' ? null : t`Name cannot be empty`), 16 | }, 17 | }); 18 | }; 19 | 20 | export default useTagForm; 21 | -------------------------------------------------------------------------------- /src/meridian/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './connectionSettings'; 2 | export * from './preferences'; 3 | export * from './transferInfo'; 4 | export * from './torrent'; 5 | export * from './torrentFilters'; 6 | export * from './category'; 7 | export * from './mainData'; 8 | export * from './peer'; 9 | export * from './sync'; 10 | export * from './session'; 11 | export * from './settings'; 12 | export * from './torrentProperties'; 13 | export * from './torrentContent'; 14 | export * from './torrentTracker'; 15 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 4 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 5 | -webkit-font-smoothing: antialiased; 6 | -moz-osx-font-smoothing: grayscale; 7 | min-height: 100vh; 8 | } 9 | 10 | body > #root { 11 | min-height: 100vh; 12 | display: flex; 13 | flex-direction: column; 14 | } 15 | 16 | ::-webkit-scrollbar { 17 | width: 0px; 18 | } 19 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import tsconfigPaths from 'vite-tsconfig-paths'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react({ 8 | babel: { 9 | // Use .babelrc files, necessary to use LinguiJS CLI 10 | babelrc: true 11 | }, 12 | }), tsconfigPaths()], 13 | build: { 14 | chunkSizeWarningLimit: 1000, 15 | }, 16 | server: { 17 | host: '127.0.0.1', 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /src/meridian/hooks/useCloseLastModal.ts: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | 3 | import { useModals } from '@mantine/modals'; 4 | 5 | export const useCloseLastModal = () => { 6 | const modals = useModals(); 7 | 8 | return useCallback(() => { 9 | const lastModal = modals.modals.length 10 | ? modals.modals[modals.modals.length - 1] 11 | : undefined; 12 | if (lastModal) { 13 | modals.closeModal(lastModal.id); 14 | } 15 | }, [modals]); 16 | }; 17 | -------------------------------------------------------------------------------- /src/meridian/models/peer.ts: -------------------------------------------------------------------------------- 1 | export interface Peer { 2 | client: string; 3 | connection: string; 4 | country: string; 5 | country_code: string; 6 | dl_speed: number; 7 | downloaded: number; 8 | up_speed: number; 9 | uploaded: number; 10 | files: string; 11 | flags: string; 12 | flags_desc: string; 13 | ip: string; 14 | port: number; 15 | progress: number; 16 | relevance: number; 17 | } 18 | 19 | export interface Peers { 20 | [ip_and_port: string]: Peer; 21 | } 22 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsxSingleQuote": true, 3 | "singleQuote": true, 4 | "semi": true, 5 | "tabWidth": 4, 6 | "trailingComma": "all", 7 | "printWidth": 100, 8 | "bracketSameLine": false, 9 | "useTabs": false, 10 | "arrowParens": "always", 11 | "endOfLine": "auto", 12 | "importOrder": [ 13 | "^react.*$", 14 | "", 15 | "^@mantine.*$", 16 | "^meridian/(.*)$", 17 | "^../.*$", 18 | "^./.*$" 19 | ], 20 | "importOrderSeparation": true, 21 | "importOrderSortSpecifiers": true 22 | } -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/types.ts: -------------------------------------------------------------------------------- 1 | import { Preferences } from 'meridian/models'; 2 | 3 | export interface SectionProps { 4 | preferences: Partial | undefined; 5 | updatePreferencesKey: ( 6 | name: keyof Preferences, 7 | value: string | boolean | number | string[], 8 | ) => void; 9 | updateBulkPreferencesKey: ( 10 | items: { 11 | name: keyof Preferences; 12 | value: string | boolean | number | string[]; 13 | }[], 14 | ) => void; 15 | } 16 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd . 4 | source configs/release.sh 5 | if [[ -z "${DEPLOY_PATH}" ]] 6 | then 7 | echo "Deploy path not set. Please set DEPLOY_PATH env var to desired path" 8 | exit 9 | else 10 | read -p "Deploying to ${DEPLOY_PATH}. Continue? (Y/N): " confirm 11 | if [[ $confirm == [yY] ]]; 12 | then 13 | rm -rf dist/ 14 | yarn build 15 | rm -rf ${DEPLOY_PATH} 16 | mkdir -p ${DEPLOY_PATH} 17 | cp -r dist/* ${DEPLOY_PATH} 18 | else 19 | exit 20 | fi 21 | fi -------------------------------------------------------------------------------- /src/meridian/torrentFilters/state/actions.ts: -------------------------------------------------------------------------------- 1 | import { TorrentFilters } from 'meridian/models'; 2 | import { BaseAction } from 'meridian/resource'; 3 | 4 | export enum TorrentFiltersActions { 5 | SET_TORRENT_FILTERS = 'SET_TORRENT_FILTERS', 6 | } 7 | 8 | export interface SetTorrentFiltersAction extends BaseAction { 9 | filters: TorrentFilters; 10 | } 11 | export const createSetTorrentFiltersAction = ( 12 | filters: TorrentFilters, 13 | ): SetTorrentFiltersAction => ({ 14 | type: TorrentFiltersActions.SET_TORRENT_FILTERS, 15 | filters, 16 | }); 17 | -------------------------------------------------------------------------------- /src/meridian/hooks/useCreateResource.ts: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | 4 | import { Resource, SetResourceParams, createResourceSetAction } from 'meridian/resource'; 5 | 6 | export const useCreateResource = (resourceName: T) => { 7 | const dispatch = useDispatch(); 8 | 9 | return useCallback( 10 | (params: SetResourceParams[T]) => { 11 | dispatch(createResourceSetAction(resourceName, params)); 12 | }, 13 | [dispatch, resourceName], 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/meridian/hooks/useFetchResource.ts: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | 4 | import { FetchResourceParams, Resource, createResourceFetchAction } from 'meridian/resource'; 5 | 6 | export const useFetchResource = (resourceName: T) => { 7 | const dispatch = useDispatch(); 8 | 9 | return useCallback( 10 | (params?: FetchResourceParams[T]) => { 11 | dispatch(createResourceFetchAction(resourceName, params)); 12 | }, 13 | [dispatch, resourceName], 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/meridian/torrent/useAddTorrentForm.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useForm } from '@mantine/form'; 4 | 5 | import { AddTorrentsParams } from 'meridian/api'; 6 | 7 | const useAddTorrentForm = () => { 8 | const initialValues: AddTorrentsParams = { 9 | urls: [], 10 | tags: [], 11 | torrents: [], 12 | paused: false, 13 | skipChecking: false, 14 | rootFolder: true, 15 | autoTMM: true, 16 | }; 17 | 18 | return useForm({ 19 | initialValues, 20 | }); 21 | }; 22 | 23 | export default useAddTorrentForm; 24 | -------------------------------------------------------------------------------- /src/meridian/hooks/useDeleteResource.ts: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | 4 | import { DeleteResourceParams, Resource, createResourceDeleteAction } from 'meridian/resource'; 5 | 6 | export const useDeleteResource = (resourceName: T) => { 7 | const dispatch = useDispatch(); 8 | 9 | return useCallback( 10 | (params: DeleteResourceParams[T]) => { 11 | dispatch(createResourceDeleteAction(resourceName, params)); 12 | }, 13 | [dispatch, resourceName], 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/meridian/generic/useWindowSize.ts: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | 3 | export interface WindowSize { 4 | height: number; 5 | width: number; 6 | } 7 | 8 | const useWindowSize = () => { 9 | const [size, setSize] = useState({ 10 | height: window.innerHeight, 11 | width: window.innerWidth, 12 | }); 13 | 14 | useEffect(() => { 15 | window.addEventListener('resize', () => { 16 | setSize({ height: window.innerHeight, width: window.innerWidth }); 17 | }); 18 | }, []); 19 | 20 | return size; 21 | }; 22 | 23 | export default useWindowSize; 24 | -------------------------------------------------------------------------------- /src/meridian/models/mainData.ts: -------------------------------------------------------------------------------- 1 | import { Category } from './category'; 2 | import { TorrentInfo } from './torrent'; 3 | import { TransferInfo } from './transferInfo'; 4 | 5 | export interface MainData { 6 | rid: number; 7 | full_update: boolean; 8 | torrents: { 9 | [hash: string]: TorrentInfo; 10 | }; 11 | torrents_removed: string[]; 12 | categories: { 13 | [name: string]: Category; 14 | }; 15 | categories_removed: string[]; 16 | tags: string[]; 17 | tags_removed: string[]; 18 | server_state: TransferInfo; 19 | trackers: { 20 | [url: string]: string[]; 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/meridian/snackbar/actions.ts: -------------------------------------------------------------------------------- 1 | export enum SnackbarAction { 2 | SHOW_SNACKBAR = 'SHOW_SNACKBAR', 3 | } 4 | 5 | export type SnackbarVariant = 'default' | 'error' | 'info' | 'success' | 'warning'; 6 | 7 | export interface ShowSnackbarAction { 8 | type: SnackbarAction; 9 | text: string; 10 | variant: SnackbarVariant; 11 | autoHideDuration?: number; 12 | } 13 | 14 | export const showSnackbarAction = ( 15 | text: string, 16 | variant: SnackbarVariant, 17 | autoHideDuration?: number, 18 | ): ShowSnackbarAction => ({ 19 | type: SnackbarAction.SHOW_SNACKBAR, 20 | text, 21 | variant, 22 | autoHideDuration, 23 | }); 24 | -------------------------------------------------------------------------------- /src/meridian/login/useLoginForm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { useForm } from '@mantine/form'; 6 | 7 | import { LoginData } from 'meridian/models'; 8 | 9 | const useLoginForm = () => { 10 | const initialValues: LoginData = { 11 | username: '', 12 | password: '', 13 | }; 14 | 15 | return useForm({ 16 | initialValues, 17 | validate: { 18 | username: (value) => (value !== '' ? null : t`Username cannot be empty`), 19 | password: (value) => (value !== '' ? null : t`Password cannot be empty`), 20 | }, 21 | }); 22 | }; 23 | 24 | export default useLoginForm; 25 | -------------------------------------------------------------------------------- /src/meridian/models/torrentContent.ts: -------------------------------------------------------------------------------- 1 | import { t } from '@lingui/macro'; 2 | 3 | export interface TorrentContent { 4 | index: string; 5 | name: string; 6 | size: number; 7 | progress: number; 8 | priority: FilePriority; 9 | is_seed: boolean; 10 | piece_range: number[]; 11 | availability: number; 12 | } 13 | 14 | export enum FilePriority { 15 | DO_NOT_DOWNLOAD = 0, 16 | NORMAL = 1, 17 | HIGH = 6, 18 | MAXIMUM = 7, 19 | } 20 | 21 | export const FilePriorityDescription = { 22 | [FilePriority.DO_NOT_DOWNLOAD]: t`Ignored`, 23 | [FilePriority.NORMAL]: t`Normal`, 24 | [FilePriority.HIGH]: t`High`, 25 | [FilePriority.MAXIMUM]: t`Maximum`, 26 | }; 27 | -------------------------------------------------------------------------------- /src/meridian/categories/useCategoryForm.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { useForm } from '@mantine/form'; 6 | 7 | import { Category } from 'meridian/models'; 8 | 9 | const useCategoryForm = (category?: Category) => { 10 | const initialValues = category ?? { 11 | name: '', 12 | savePath: '', 13 | }; 14 | 15 | return useForm({ 16 | initialValues, 17 | validate: { 18 | name: (value) => (value !== '' ? null : t`Name cannot be empty`), 19 | savePath: (value) => (value !== '' ? null : t`Save path cannot be empty`), 20 | }, 21 | }); 22 | }; 23 | 24 | export default useCategoryForm; 25 | -------------------------------------------------------------------------------- /src/meridian/torrent/card/fileInfo.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Group, MantineStyleSystemProps } from '@mantine/core'; 6 | 7 | import { LabelWithText } from 'meridian/generic'; 8 | import { bytesToSize } from 'meridian/utils'; 9 | 10 | interface Props extends MantineStyleSystemProps { 11 | savePath: string; 12 | size: number; 13 | } 14 | 15 | const FileInfo = ({ savePath, size, ...props }: Props) => ( 16 | 17 | 18 | 19 | 20 | ); 21 | 22 | export default memo(FileInfo); 23 | -------------------------------------------------------------------------------- /src/meridian/testing/helpers.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | 4 | import { ModalsProvider } from '@mantine/modals'; 5 | 6 | import { createStore } from 'meridian/state'; 7 | 8 | /* eslint-disable-next-line no-restricted-imports */ 9 | import { GlobalState } from 'meridian/state/types'; 10 | 11 | export const withReduxState = (element: React.ReactNode, state?: GlobalState) => ( 12 | {element} 13 | ); 14 | 15 | export const withMantineModals = (element: React.ReactNode) => ( 16 | {element} 17 | ); 18 | 19 | export const addTestId = (testId: string) => ({ 20 | ['data-testid']: testId, 21 | }); 22 | -------------------------------------------------------------------------------- /src/meridian/generic/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Page } from './page'; 2 | export { default as DrawerPage } from './drawerPage'; 3 | export { default as Dropzone } from './dropzone'; 4 | export { default as ContextMenu } from './contextMenu'; 5 | export { ColorSchemeToggle, SegmentedColorSchemeToggle } from './colorSchemeToggle'; 6 | export { default as useWindowSize } from './useWindowSize'; 7 | export { default as useIsSmallDevice } from './useIsSmallDevice'; 8 | export { default as ScrollToTopAffix } from './scrollToTopAffix'; 9 | export { default as LabelWithText } from './labelWithText'; 10 | export { default as LabelWithBadge } from './labelWithBadge'; 11 | export * from './commonConfigs'; 12 | export type { ContextMenuItem } from './contextMenu'; 13 | -------------------------------------------------------------------------------- /src/meridian/hooks/useToggleTheme.ts: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { createSetSettingsAction, selectSettings } from 'meridian/settings'; 5 | 6 | export const useToggleTheme = () => { 7 | const settings = useSelector(selectSettings); 8 | const dispatch = useDispatch(); 9 | 10 | const theme = settings.darkMode ? 'dark' : 'light'; 11 | const toggleTheme = useCallback(() => { 12 | dispatch( 13 | createSetSettingsAction({ 14 | ...settings, 15 | darkMode: !settings.darkMode, 16 | }), 17 | ); 18 | }, [dispatch, settings]); 19 | 20 | return { 21 | toggleTheme, 22 | theme, 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/meridian/mock/mainData.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { MainData, TorrentInfo } from 'meridian/models'; 3 | 4 | import { MockCategories } from './categories'; 5 | import { MockTags } from './tags'; 6 | import { MockTorrents } from './torrents'; 7 | import { MockTransferInfo } from './transferInfo'; 8 | 9 | export const MockMainData: MainData = { 10 | categories: MockCategories, 11 | categories_removed: [], 12 | full_update: false, 13 | rid: 1, 14 | server_state: MockTransferInfo, 15 | tags: MockTags, 16 | tags_removed: [], 17 | torrents: (MockTorrents as TorrentInfo[]).reduce( 18 | (res, el) => ((res[el.hash] = el), res), 19 | {} as Record, 20 | ), 21 | torrents_removed: [], 22 | trackers: {}, 23 | }; 24 | -------------------------------------------------------------------------------- /src/meridian/models/torrentTracker.ts: -------------------------------------------------------------------------------- 1 | import { t } from '@lingui/macro'; 2 | 3 | export interface TorrentTracker { 4 | url: string; 5 | status: TorrentTrackerStatus; 6 | tier: number; 7 | num_peers: number; 8 | num_seeds: number; 9 | num_leeches: number; 10 | num_downloaded: number; 11 | msg: string; 12 | } 13 | 14 | export enum TorrentTrackerStatus { 15 | DISABLED = 0, 16 | NOT_CONTACTED = 1, 17 | CONTACTED = 2, 18 | UPDATING = 3, 19 | ERROR = 4, 20 | } 21 | 22 | export const TorrentTrackerStatusDescription = { 23 | [TorrentTrackerStatus.DISABLED]: t`Disabled`, 24 | [TorrentTrackerStatus.NOT_CONTACTED]: t`Not contacted`, 25 | [TorrentTrackerStatus.CONTACTED]: t`Contacted`, 26 | [TorrentTrackerStatus.UPDATING]: t`Updating`, 27 | [TorrentTrackerStatus.ERROR]: t`Error`, 28 | }; 29 | -------------------------------------------------------------------------------- /src/meridian/models/sync.ts: -------------------------------------------------------------------------------- 1 | import { Category } from './category'; 2 | import { Peer } from './peer'; 3 | import { TorrentInfo } from './torrent'; 4 | import { TransferInfo } from './transferInfo'; 5 | 6 | export interface SyncMainData { 7 | rid: number; 8 | full_update?: boolean; 9 | categories?: { 10 | [name: string]: Category; 11 | }; 12 | categories_removed?: string[]; 13 | server_state?: TransferInfo; 14 | tags?: string[]; 15 | tags_removed?: string[]; 16 | torrents?: { 17 | [hash: string]: TorrentInfo; 18 | }; 19 | torrents_removed?: string[]; 20 | trackers?: { 21 | [url: string]: string[]; 22 | }; 23 | trackers_removed: string[]; 24 | } 25 | 26 | export interface SyncPeers { 27 | rid: number; 28 | peers: { 29 | [ip_and_port: string]: Peer; 30 | }; 31 | peers_removed?: string[]; 32 | } 33 | -------------------------------------------------------------------------------- /src/meridian/mock/preferences.ts: -------------------------------------------------------------------------------- 1 | import { Preferences } from 'meridian/models'; 2 | 3 | export const MockPreferences: Preferences = { 4 | create_subfolder_enabled: true, 5 | start_paused_enabled: false, 6 | preallocate_all: false, 7 | incomplete_files_ext: false, 8 | save_path: 'some/save/path', 9 | auto_tmm_enabled: true, 10 | category_changed_tmm_enabled: true, 11 | save_path_changed_tmm_enabled: true, 12 | torrent_changed_tmm_enabled: true, 13 | temp_path_enabled: true, 14 | temp_path: 'some/temp/path', 15 | autorun_enabled: true, 16 | autorun_program: 'some/autorun/program', 17 | alternative_webui_enabled: true, 18 | alternative_webui_path: 'some/webui/path', 19 | bypass_auth_subnet_whitelist_enabled: true, 20 | bypass_auth_subnet_whitelist: '1.1.1.1', 21 | web_ui_username: 'username', 22 | web_ui_password: 'password', 23 | }; 24 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/speed/globalRateLimitsSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Accordion, NumberInput } from '@mantine/core'; 6 | 7 | import { SectionProps } from '../types'; 8 | 9 | const GlobalRateLimitsSection = ({ preferences, updatePreferencesKey }: SectionProps) => ( 10 | 11 | updatePreferencesKey('up_limit', value as number)} 15 | /> 16 | updatePreferencesKey('dl_limit', value as number)} 21 | /> 22 | 23 | ); 24 | 25 | export default GlobalRateLimitsSection; 26 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | 4 | import { ModalsProvider } from '@mantine/modals'; 5 | import { NotificationsProvider } from '@mantine/notifications'; 6 | 7 | import AppThemeProvider from 'meridian/ThemeProvider'; 8 | import { I18nProvider } from 'meridian/i18n'; 9 | import { AppRouter } from 'meridian/navigation'; 10 | import { createStore } from 'meridian/state'; 11 | 12 | const App = () => ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | 26 | export default App; 27 | -------------------------------------------------------------------------------- /src/meridian/mock/transferInfo.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionStatus, TransferInfo } from 'meridian/models'; 2 | 3 | export const MockTransferInfo: TransferInfo = { 4 | connection_status: ConnectionStatus.CONNECTED, 5 | dl_info_speed: 10000, 6 | dl_info_data: 200000, 7 | up_info_speed: 10000, 8 | up_info_data: 200000, 9 | dl_rate_limit: 4000, 10 | up_rate_limit: 4000, 11 | dht_nodes: 10, 12 | queueing: false, 13 | use_alt_speed_limits: false, 14 | refresh_interval: 5, 15 | alltime_dl: 10000, 16 | alltime_ul: 10000, 17 | average_time_queue: 10, 18 | free_space_on_disk: 10000, 19 | global_ratio: '10', 20 | queued_io_jobs: 0, 21 | read_cache_hits: '10', 22 | read_cache_overload: '10', 23 | total_buffers_size: 10000, 24 | total_peer_connections: 2, 25 | total_queued_size: 10, 26 | total_wasted_session: 10, 27 | write_cache_overload: '10', 28 | }; 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "DOM", 7 | "DOM.Iterable", 8 | "ESNext" 9 | ], 10 | "allowJs": false, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "ESNext", 17 | "moduleResolution": "Node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "baseUrl": "src", 23 | "paths": { 24 | "meridian/*": [ 25 | "meridian/*" 26 | ], 27 | "locales/*": [ 28 | "locales/*" 29 | ], 30 | }, 31 | }, 32 | "include": [ 33 | "src" 34 | ], 35 | "references": [ 36 | { 37 | "path": "./tsconfig.node.json" 38 | } 39 | ] 40 | } -------------------------------------------------------------------------------- /src/meridian/generic/contextMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { MantineStyleSystemProps, Menu } from '@mantine/core'; 4 | 5 | export interface ContextMenuItem { 6 | text: string; 7 | icon?: React.ReactElement; 8 | callback: () => void; 9 | } 10 | 11 | interface Props extends MantineStyleSystemProps { 12 | items: ContextMenuItem[]; 13 | control: React.ReactElement; 14 | } 15 | 16 | const ContextMenu = ({ items, control, ...props }: Props) => ( 17 | 18 | {control} 19 | 20 | {items.map((item) => ( 21 | 22 | {item.text} 23 | 24 | ))} 25 | 26 | 27 | ); 28 | 29 | export default ContextMenu; 30 | -------------------------------------------------------------------------------- /src/meridian/home/hooks/useFetchTimer.ts: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { useRefreshMainData } from 'meridian/mainData'; 5 | import { selectSettings } from 'meridian/settings'; 6 | 7 | const useFetchTimer = () => { 8 | const refreshMainData = useRefreshMainData(); 9 | const settings = useSelector(selectSettings); 10 | 11 | useEffect(() => { 12 | let timer: NodeJS.Timer; 13 | refreshMainData(); 14 | if (settings.autoRefresh) { 15 | timer = setInterval(() => { 16 | refreshMainData(); 17 | }, settings.autoRefreshInterval * 1000); 18 | } 19 | 20 | return () => { 21 | if (timer) { 22 | clearInterval(timer); 23 | } 24 | }; 25 | }, [refreshMainData, settings.autoRefresh, settings.autoRefreshInterval]); 26 | }; 27 | 28 | export default useFetchTimer; 29 | -------------------------------------------------------------------------------- /src/meridian/home/hooks/useManageSelection.ts: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react'; 2 | 3 | const useManageSelection = () => { 4 | const [selectionEnabled, setSelectionEnabled] = useState(false); 5 | const [keys, setKeys] = useState([]); 6 | 7 | const onSelectionChanged = useCallback( 8 | (key: string, selected: boolean) => { 9 | if (!selected && keys.includes(key)) { 10 | setKeys(keys.filter((x) => x !== key)); 11 | } else if (selected && !keys.includes(key)) { 12 | setKeys([...keys, key]); 13 | } 14 | }, 15 | [keys, setKeys], 16 | ); 17 | 18 | const clearSelection = () => setKeys([]); 19 | 20 | return { 21 | selectionEnabled, 22 | setSelectionEnabled, 23 | keys, 24 | onSelectionChanged, 25 | clearSelection, 26 | }; 27 | }; 28 | 29 | export default useManageSelection; 30 | -------------------------------------------------------------------------------- /src/meridian/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { MantineProvider, MantineThemeOverride } from '@mantine/core'; 5 | 6 | import { selectSettings } from 'meridian/settings'; 7 | 8 | interface Props { 9 | children: React.ReactNode; 10 | } 11 | 12 | const ThemeProvider = ({ children }: Props) => { 13 | const settings = useSelector(selectSettings); 14 | const theme: MantineThemeOverride = useMemo( 15 | () => ({ 16 | colorScheme: settings.darkMode ? 'dark' : 'light', 17 | fontFamily: 'Inter var', 18 | fontSizes: { 19 | sm: 16, 20 | }, 21 | }), 22 | [settings], 23 | ); 24 | document.body.style.backgroundColor = settings.darkMode ? '#2C2E33' : '#FFFFFF'; 25 | 26 | return {children}; 27 | }; 28 | 29 | export default ThemeProvider; 30 | -------------------------------------------------------------------------------- /src/meridian/torrent/card/__tests__/card.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import { MockTorrents } from 'meridian/mock'; 6 | import { TorrentInfo } from 'meridian/models'; 7 | import { withMantineModals, withReduxState } from 'meridian/testing'; 8 | 9 | import TorrentCard from '../card'; 10 | 11 | describe('Card', () => { 12 | it('should render as expected', () => { 13 | const result = render( 14 | withReduxState( 15 | withMantineModals( 16 | , 22 | ), 23 | ), 24 | ); 25 | expect(result.asFragment()).toMatchSnapshot(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/meridian/home/headerContent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Plus, Settings } from 'tabler-icons-react'; 4 | 5 | import { ActionIcon } from '@mantine/core'; 6 | 7 | import { ContextMenu } from 'meridian/generic'; 8 | import { useAddTorrentsModal } from 'meridian/torrent'; 9 | 10 | import { useHeaderMenuItems } from './hooks'; 11 | 12 | const HeaderContent = () => { 13 | const openAddTorrentsModal = useAddTorrentsModal(); 14 | const items = useHeaderMenuItems(); 15 | return ( 16 | <> 17 | 18 | 19 | 20 | 24 | 25 | 26 | } 27 | /> 28 | > 29 | ); 30 | }; 31 | 32 | export default HeaderContent; 33 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/connection/listeningPortSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Accordion, NumberInput, Switch } from '@mantine/core'; 6 | 7 | import { SectionProps } from '../types'; 8 | 9 | const ListeningPortSection = ({ preferences, updatePreferencesKey }: SectionProps) => ( 10 | 11 | updatePreferencesKey('listen_port', value as number)} 15 | /> 16 | updatePreferencesKey('upnp', value.target.checked)} 21 | /> 22 | 23 | ); 24 | 25 | export default ListeningPortSection; 26 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | node: circleci/node@5.0.2 5 | 6 | jobs: 7 | lint: 8 | executor: node/default 9 | steps: 10 | - checkout 11 | - node/install-packages: 12 | pkg-manager: yarn 13 | - run: 14 | command: yarn lint 15 | name: Check linting rules 16 | - run: 17 | command: yarn formatting 18 | name: Check formatting rules 19 | - run: 20 | command: yarn types 21 | name: Check types 22 | test: 23 | executor: node/default 24 | steps: 25 | - checkout 26 | - node/install-packages: 27 | pkg-manager: yarn 28 | - run: 29 | command: yarn test 30 | name: Run tests 31 | 32 | workflows: 33 | lint_and_test: 34 | jobs: 35 | - lint 36 | - test 37 | -------------------------------------------------------------------------------- /src/meridian/settings/modals/useSettings.ts: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { DefaultMantineColor } from '@mantine/core'; 5 | 6 | import { Settings, TorrentStateDescription } from 'meridian/models'; 7 | 8 | import { createSetSettingsAction, selectSettings } from '../state'; 9 | 10 | const useSettings = () => { 11 | const settings = useSelector(selectSettings); 12 | const dispatch = useDispatch(); 13 | 14 | const handleSettingsChange = useCallback( 15 | ( 16 | key: keyof Settings, 17 | value: boolean | number | string | Record, 18 | ) => { 19 | dispatch(createSetSettingsAction({ ...settings, [key]: value })); 20 | }, 21 | [dispatch, settings], 22 | ); 23 | 24 | return { 25 | settings, 26 | handleSettingsChange, 27 | }; 28 | }; 29 | 30 | export default useSettings; 31 | -------------------------------------------------------------------------------- /src/meridian/models/torrentProperties.ts: -------------------------------------------------------------------------------- 1 | export interface TorrentProperties { 2 | save_path: string; 3 | creation_date: number; 4 | piece_size: number; 5 | comment: string; 6 | total_wasted: number; 7 | total_uploaded: number; 8 | total_uploaded_session: number; 9 | total_downloaded: number; 10 | total_downloaded_session: number; 11 | up_limit: number; 12 | dl_limit: number; 13 | time_elapsed: number; 14 | seeding_time: number; 15 | nb_connections: number; 16 | nb_connections_limit: number; 17 | share_ratio: number; 18 | addition_date: number; 19 | created_by: string; 20 | dl_speed_avg: number; 21 | dl_speed: number; 22 | eta: number; 23 | last_seen: number; 24 | peers: number; 25 | peers_total: number; 26 | pieces_have: number; 27 | pieces_num: number; 28 | reannounce: number; 29 | seeds: number; 30 | seeds_total: number; 31 | total_size: number; 32 | up_speed_avg: number; 33 | up_speed: number; 34 | } 35 | -------------------------------------------------------------------------------- /src/meridian/navigation/router.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-imports */ 2 | import React, { useLayoutEffect, useState } from 'react'; 3 | import { Route, Router, Routes } from 'react-router-dom'; 4 | 5 | import HomePage from 'meridian/home/homePage'; 6 | import LoginPage from 'meridian/login/loginPage'; 7 | 8 | import { history } from './history'; 9 | import { AppRoutes } from './types'; 10 | 11 | const AppRouter = () => { 12 | const [state, setState] = useState({ 13 | action: history.action, 14 | location: history.location, 15 | }); 16 | 17 | useLayoutEffect(() => history.listen(setState), []); 18 | 19 | return ( 20 | 21 | 22 | } /> 23 | } /> 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default AppRouter; 30 | -------------------------------------------------------------------------------- /src/meridian/models/transferInfo.ts: -------------------------------------------------------------------------------- 1 | export enum ConnectionStatus { 2 | CONNECTED = 'connected', 3 | FIREWALED = 'firewaled', 4 | DISCONNECTED = 'disconnected', 5 | } 6 | 7 | export interface TransferInfo { 8 | alltime_dl: number; 9 | alltime_ul: number; 10 | average_time_queue: number; 11 | connection_status: ConnectionStatus; 12 | dht_nodes: number; 13 | dl_info_data: number; 14 | dl_info_speed: number; 15 | dl_rate_limit: number; 16 | free_space_on_disk: number; 17 | global_ratio: string; 18 | queued_io_jobs: number; 19 | queueing: boolean; 20 | read_cache_hits: string; 21 | read_cache_overload: string; 22 | refresh_interval: number; 23 | total_buffers_size: number; 24 | total_peer_connections: number; 25 | total_queued_size: number; 26 | total_wasted_session: number; 27 | up_info_data: number; 28 | up_info_speed: number; 29 | up_rate_limit: number; 30 | use_alt_speed_limits: boolean; 31 | write_cache_overload: string; 32 | } 33 | -------------------------------------------------------------------------------- /src/meridian/generic/__tests__/scrollToTopAffix.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, screen } from '@testing-library/react'; 4 | import userEvent from '@testing-library/user-event'; 5 | 6 | const mockScrollTo = jest.fn(); 7 | 8 | jest.mock('@mantine/hooks', () => ({ 9 | ...jest.requireActual('@mantine/hooks'), 10 | useWindowScroll: jest.fn().mockReturnValue([{ y: 10 }, mockScrollTo]), 11 | })); 12 | 13 | import ScrollToTopAffix from '../scrollToTopAffix'; 14 | 15 | describe('ScrollToTopAffix', () => { 16 | beforeEach(() => { 17 | mockScrollTo.mockReset(); 18 | }); 19 | 20 | it('should render as expected', () => { 21 | const result = render(); 22 | expect(result.asFragment()).toMatchSnapshot(); 23 | }); 24 | 25 | it('should call scroll to top on click', async () => { 26 | render(); 27 | await userEvent.click(screen.getByTestId('affixButton')); 28 | expect(mockScrollTo).toHaveBeenCalled(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/meridian/generic/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Box, createStyles, useMantineTheme } from '@mantine/core'; 4 | 5 | interface Props { 6 | children: React.ReactNode; 7 | } 8 | 9 | const Page = ({ children }: Props) => { 10 | const styles = useStyles(); 11 | return ( 12 | 13 | {children} 14 | 15 | ); 16 | }; 17 | 18 | const useStyles = () => { 19 | const theme = useMantineTheme(); 20 | 21 | return createStyles({ 22 | root: { 23 | height: '100vh', 24 | display: 'flex', 25 | flexDirection: 'column', 26 | }, 27 | content: { 28 | display: 'flex', 29 | overflow: 'scroll', 30 | flex: 1, 31 | flexDirection: 'column', 32 | backgroundColor: theme.colorScheme === 'light' ? theme.white : theme.colors.dark[5], 33 | }, 34 | })(); 35 | }; 36 | 37 | export default Page; 38 | -------------------------------------------------------------------------------- /src/meridian/state/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-imports */ 2 | import { Resource, ResourceState } from 'meridian/resource'; 3 | import { SessionState } from 'meridian/session/state/types'; 4 | import { SettingsState } from 'meridian/settings/state/types'; 5 | import { TorrentFiltersState } from 'meridian/torrentFilters/state/types'; 6 | 7 | export interface GlobalState { 8 | mainDataState: ResourceState; 9 | torrentState: ResourceState; 10 | torrentPropertiesState: ResourceState; 11 | torrentContentState: ResourceState; 12 | torrentTrackersState: ResourceState; 13 | torrentFiltersState: TorrentFiltersState; 14 | sessionState: SessionState; 15 | settingsState: SettingsState; 16 | transferInfoState: ResourceState; 17 | preferencesState: ResourceState; 18 | categoriesState: ResourceState; 19 | tagsState: ResourceState; 20 | } 21 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/downloads/addingTorrentSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Accordion, Switch } from '@mantine/core'; 6 | 7 | import { SectionProps } from '../types'; 8 | 9 | const AddingTorrentSection = ({ preferences, updatePreferencesKey }: SectionProps) => ( 10 | 11 | updatePreferencesKey('start_paused_enabled', value.target.checked)} 15 | /> 16 | 21 | updatePreferencesKey('create_subfolder_enabled', value.target.checked) 22 | } 23 | /> 24 | 25 | ); 26 | 27 | export default AddingTorrentSection; 28 | -------------------------------------------------------------------------------- /src/meridian/torrentFilters/state/reducer.ts: -------------------------------------------------------------------------------- 1 | import { LocalStorage, LocalStorageKey } from 'meridian/localStorage'; 2 | import { BaseAction } from 'meridian/resource'; 3 | 4 | import { SetTorrentFiltersAction, TorrentFiltersActions } from './actions'; 5 | import { TorrentFiltersState } from './types'; 6 | 7 | const initialState: TorrentFiltersState = { 8 | filters: { 9 | name: '', 10 | states: [], 11 | categories: [], 12 | tags: [], 13 | }, 14 | }; 15 | 16 | export const torrentFiltersReducer = ( 17 | state: TorrentFiltersState = initialState, 18 | action: BaseAction, 19 | ): TorrentFiltersState => { 20 | switch (action.type) { 21 | case TorrentFiltersActions.SET_TORRENT_FILTERS: { 22 | const setAction = action as SetTorrentFiltersAction; 23 | LocalStorage.setValue(LocalStorageKey.TORRENT_FILTERS, setAction.filters); 24 | return { 25 | ...state, 26 | filters: setAction.filters, 27 | }; 28 | } 29 | default: { 30 | return state; 31 | } 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/meridian/generic/labelWithText.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { 4 | Box, 5 | DefaultMantineColor, 6 | MantineStyleSystemProps, 7 | Text, 8 | createStyles, 9 | } from '@mantine/core'; 10 | 11 | interface Props extends MantineStyleSystemProps { 12 | label: string; 13 | text: string; 14 | color?: DefaultMantineColor; 15 | icon?: React.ReactNode; 16 | } 17 | 18 | const LabelWithText = ({ label, text, color, icon, ...rest }: Props) => { 19 | const styles = useStyles(); 20 | 21 | return ( 22 | 23 | {icon} 24 | 25 | {label} 26 | 27 | {text} 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | const useStyles = createStyles({ 35 | root: { 36 | display: 'flex', 37 | flexDirection: 'row', 38 | alignItems: 'center', 39 | }, 40 | }); 41 | 42 | export default LabelWithText; 43 | -------------------------------------------------------------------------------- /src/meridian/torrent/card/connectionInfo.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Group, MantineStyleSystemProps } from '@mantine/core'; 6 | 7 | import { LabelWithText } from 'meridian/generic'; 8 | import { TORRENT_INVALID_ETA } from 'meridian/models'; 9 | import { calculateEtaString } from 'meridian/utils'; 10 | 11 | interface Props extends MantineStyleSystemProps { 12 | seeders: number; 13 | leechers: number; 14 | ratio: number; 15 | progress: number; 16 | eta: number; 17 | } 18 | 19 | const ConnectionInfo = ({ seeders, leechers, ratio, progress, eta, ...props }: Props) => ( 20 | 21 | 22 | 23 | 24 | {progress !== 1 && eta !== TORRENT_INVALID_ETA ? ( 25 | 26 | ) : null} 27 | 28 | ); 29 | 30 | export default memo(ConnectionInfo); 31 | -------------------------------------------------------------------------------- /src/meridian/i18n/i18nProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { i18n } from '@lingui/core'; 5 | import { I18nProvider as LibProvider } from '@lingui/react'; 6 | 7 | /* eslint-disable-next-line no-restricted-imports */ 8 | import { messages as enMessages } from 'locales/en/messages'; 9 | 10 | /* eslint-disable-next-line no-restricted-imports */ 11 | import { messages as roMessages } from 'locales/ro/messages'; 12 | 13 | import { selectSettings } from 'meridian/settings'; 14 | 15 | import { Language } from './types'; 16 | 17 | interface Props { 18 | children: React.ReactNode; 19 | } 20 | 21 | i18n.load({ 22 | [Language.ENGLISH]: enMessages, 23 | [Language.ROMANIAN]: roMessages, 24 | }); 25 | 26 | i18n.activate(Language.ENGLISH); 27 | 28 | const I18nProvider = ({ children }: Props) => { 29 | const settings = useSelector(selectSettings); 30 | 31 | useEffect(() => { 32 | i18n.activate(settings.language || Language.ENGLISH); 33 | }, [settings]); 34 | 35 | return {children}; 36 | }; 37 | 38 | export default I18nProvider; 39 | -------------------------------------------------------------------------------- /src/meridian/preferences/state/saga.ts: -------------------------------------------------------------------------------- 1 | import { t } from '@lingui/macro'; 2 | import { call, put, takeLatest } from 'redux-saga/effects'; 3 | 4 | import { 5 | Resource, 6 | ResourceSetAction, 7 | createFetchResourceSaga, 8 | createResourceFetchAction, 9 | createSetResourceSaga, 10 | } from 'meridian/resource'; 11 | import { showSnackbarAction } from 'meridian/snackbar'; 12 | 13 | import { PreferencesActions } from './reducer'; 14 | 15 | function* setPreferencesSaga(action: ResourceSetAction) { 16 | try { 17 | yield call(createSetResourceSaga(Resource.PREFERENCES), action); 18 | yield put(createResourceFetchAction(Resource.PREFERENCES)); 19 | yield put(showSnackbarAction(t`Preferences saved successfully!`, 'success', 2000)); 20 | } catch (error) { 21 | yield put(showSnackbarAction(t`Failed to save preferences!`, 'error', 2000)); 22 | } 23 | } 24 | 25 | function* preferencesSaga() { 26 | yield takeLatest(PreferencesActions.FETCH, createFetchResourceSaga(Resource.PREFERENCES)); 27 | yield takeLatest(PreferencesActions.POST, setPreferencesSaga); 28 | } 29 | 30 | export default preferencesSaga; 31 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/connection/ipFilteringSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Accordion, Switch, TextInput, Textarea } from '@mantine/core'; 6 | 7 | import { SectionProps } from '../types'; 8 | 9 | const IpFilteringSection = ({ preferences, updatePreferencesKey }: SectionProps) => ( 10 | 11 | updatePreferencesKey('ip_filter_path', value.target.value)} 15 | /> 16 | updatePreferencesKey('ip_filter_trackers', value.target.checked)} 21 | /> 22 | updatePreferencesKey('banned_IPs', value.target.value)} 27 | /> 28 | 29 | ); 30 | 31 | export default IpFilteringSection; 32 | -------------------------------------------------------------------------------- /src/meridian/snackbar/saga.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { call, takeLatest } from 'redux-saga/effects'; 4 | import { Check } from 'tabler-icons-react'; 5 | 6 | import { showNotification } from '@mantine/notifications'; 7 | 8 | import { ShowSnackbarAction, SnackbarAction } from './actions'; 9 | 10 | function* showSnackbarSaga(action: ShowSnackbarAction) { 11 | switch (action.variant) { 12 | case 'error': { 13 | yield call(showNotification, { 14 | message: action.text, 15 | autoClose: action.autoHideDuration, 16 | radius: 'lg', 17 | color: 'red', 18 | icon: , 19 | }); 20 | return; 21 | } 22 | case 'success': 23 | default: { 24 | yield call(showNotification, { 25 | message: action.text, 26 | autoClose: action.autoHideDuration, 27 | radius: 'lg', 28 | color: 'green', 29 | icon: , 30 | }); 31 | } 32 | } 33 | } 34 | 35 | function* snackbarSaga() { 36 | yield takeLatest(SnackbarAction.SHOW_SNACKBAR, showSnackbarSaga); 37 | } 38 | 39 | export default snackbarSaga; 40 | -------------------------------------------------------------------------------- /src/meridian/torrent/card/progressIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | 3 | import { Box, MantineStyleSystemProps, Progress, Text, createStyles } from '@mantine/core'; 4 | 5 | interface Props extends MantineStyleSystemProps { 6 | progress: number; 7 | } 8 | 9 | const ProgressIndicator = ({ progress, ...props }: Props) => { 10 | const styles = useStyles(); 11 | 12 | const color = progress === 100 ? 'green' : 'cyan'; 13 | const animate = progress !== 100; 14 | 15 | return ( 16 | 17 | 18 | {progress.toFixed(0)}% 19 | 20 | 27 | 28 | ); 29 | }; 30 | 31 | const useStyles = createStyles({ 32 | root: { 33 | flex: 1, 34 | display: 'flex', 35 | flexDirection: 'row', 36 | alignItems: 'center', 37 | }, 38 | progress: { 39 | flex: 1, 40 | }, 41 | }); 42 | 43 | export default memo(ProgressIndicator); 44 | -------------------------------------------------------------------------------- /src/meridian/generic/scrollToTopAffix.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ArrowUp } from 'tabler-icons-react'; 4 | 5 | import { ActionIcon, Affix, Transition } from '@mantine/core'; 6 | import { useWindowScroll } from '@mantine/hooks'; 7 | 8 | import { addTestId } from 'meridian/testing'; 9 | 10 | const ScrollToTopAffix = () => { 11 | const [scroll, scrollTo] = useWindowScroll(); 12 | 13 | const onClick = () => { 14 | scrollTo({ y: 0 }); 15 | }; 16 | 17 | return ( 18 | 19 | 0}> 20 | {(transitionStyles) => ( 21 | 30 | 31 | 32 | )} 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default ScrollToTopAffix; 39 | -------------------------------------------------------------------------------- /src/meridian/transferInfo/card.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { t } from '@lingui/macro'; 5 | 6 | import { Card, LoadingOverlay } from '@mantine/core'; 7 | 8 | import { LabelWithText } from 'meridian/generic'; 9 | import { selectMainData } from 'meridian/mainData'; 10 | import { bytesToSize } from 'meridian/utils'; 11 | 12 | const TransferInfoCard = () => { 13 | const mainData = useSelector(selectMainData); 14 | const transferInfo = mainData?.server_state; 15 | 16 | if (!transferInfo) { 17 | return ; 18 | } 19 | 20 | return ( 21 | 22 | 26 | 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default TransferInfoCard; 37 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/speed/rateLimitsSettingsSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Accordion, Switch } from '@mantine/core'; 6 | 7 | import { SectionProps } from '../types'; 8 | 9 | const RateLimitsSettingsSection = ({ preferences, updatePreferencesKey }: SectionProps) => ( 10 | 11 | updatePreferencesKey('limit_utp_rate', value.target.checked)} 15 | /> 16 | updatePreferencesKey('limit_tcp_overhead', value.target.checked)} 21 | /> 22 | updatePreferencesKey('limit_lan_peers', value.target.checked)} 27 | /> 28 | 29 | ); 30 | 31 | export default RateLimitsSettingsSection; 32 | -------------------------------------------------------------------------------- /src/meridian/session/state/reducer.ts: -------------------------------------------------------------------------------- 1 | import { BaseAction } from 'meridian/resource'; 2 | 3 | import { SessionActions, SetVersionsAction } from './actions'; 4 | import { SessionState } from './types'; 5 | 6 | const initialState: SessionState = { 7 | loggedIn: false, 8 | version: '', 9 | apiVersion: '', 10 | }; 11 | 12 | export const sessionReducer = ( 13 | state: SessionState = initialState, 14 | action: BaseAction, 15 | ): SessionState => { 16 | switch (action.type) { 17 | case SessionActions.LOGIN_SUCCESS: { 18 | return { 19 | ...state, 20 | loggedIn: true, 21 | }; 22 | } 23 | case SessionActions.LOGIN_FAIL: 24 | case SessionActions.LOGOUT_SUCCESS: 25 | case SessionActions.LOGOUT_FAIL: { 26 | return { 27 | ...state, 28 | loggedIn: false, 29 | }; 30 | } 31 | case SessionActions.SET_VERSIONS: { 32 | const setVersionsAction = action as SetVersionsAction; 33 | return { 34 | ...state, 35 | version: setVersionsAction.version, 36 | apiVersion: setVersionsAction.apiVersion, 37 | }; 38 | } 39 | default: { 40 | return state; 41 | } 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /src/meridian/torrent/card/titleAndMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | 3 | import { Menu } from 'tabler-icons-react'; 4 | 5 | import { ActionIcon, Box, MantineStyleSystemProps, Text, createStyles } from '@mantine/core'; 6 | 7 | import { ContextMenu } from 'meridian/generic'; 8 | 9 | import useContextMenuItems from '../useContextMenuItems'; 10 | 11 | interface Props extends MantineStyleSystemProps { 12 | hash: string; 13 | name: string; 14 | } 15 | 16 | const TitleAndMenu = ({ hash, name, ...props }: Props) => { 17 | const styles = useStyles(); 18 | const contextMenuItems = useContextMenuItems(hash, name); 19 | 20 | return ( 21 | 22 | 23 | {name} 24 | 25 | 29 | 30 | 31 | } 32 | /> 33 | 34 | ); 35 | }; 36 | 37 | const useStyles = createStyles({ 38 | root: { 39 | display: 'flex', 40 | flexDirection: 'row', 41 | justifyContent: 'space-between', 42 | }, 43 | }); 44 | 45 | export default memo(TitleAndMenu); 46 | -------------------------------------------------------------------------------- /src/meridian/resource/createSetResourceSaga.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-imports */ 2 | import { apply, put } from 'redux-saga/effects'; 3 | 4 | import { Api, ApiError } from 'meridian/api'; 5 | import { history } from 'meridian/navigation/history'; 6 | import { AppRoutes } from 'meridian/navigation/types'; 7 | 8 | import { 9 | ResourceSetAction, 10 | createResourceFetchAction, 11 | createResourceSetFailAction, 12 | createResourceSetSuccessAction, 13 | } from './createResourceReducer'; 14 | import { Resource } from './types'; 15 | 16 | export const createSetResourceSaga = (resourceName: T) => { 17 | function* setResource(action: ResourceSetAction) { 18 | const api = new Api(); 19 | try { 20 | yield apply(api, api.setResource, [resourceName, action.params]); 21 | yield put(createResourceSetSuccessAction(resourceName, action.params)); 22 | yield put(createResourceFetchAction(resourceName)); 23 | } catch (error) { 24 | const { status } = error as ApiError; 25 | if (status === 403) { 26 | history.replace(AppRoutes.LOGIN); 27 | } 28 | yield put(createResourceSetFailAction(resourceName, action.params, error as Error)); 29 | throw error; 30 | } 31 | } 32 | 33 | return setResource; 34 | }; 35 | -------------------------------------------------------------------------------- /src/meridian/resource/createFetchResourceSaga.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-imports */ 2 | import { apply, put } from 'redux-saga/effects'; 3 | 4 | import { Api, ApiError } from 'meridian/api'; 5 | import { history } from 'meridian/navigation/history'; 6 | import { AppRoutes } from 'meridian/navigation/types'; 7 | 8 | import { 9 | ResourceFetchAction, 10 | createResourceFetchFailAction, 11 | createResourceFetchSuccessAction, 12 | } from './createResourceReducer'; 13 | import { Resource, ResourceDataType } from './types'; 14 | 15 | export const createFetchResourceSaga = (resourceName: T) => { 16 | function* fetchResource(action: ResourceFetchAction) { 17 | const api = new Api(); 18 | try { 19 | const data: ResourceDataType[T] = yield apply(api, api.fetchResource, [ 20 | resourceName, 21 | action.params, 22 | ]); 23 | yield put(createResourceFetchSuccessAction(resourceName, data, action.params)); 24 | } catch (error) { 25 | const { status } = error as ApiError; 26 | if (status === 403) { 27 | history.replace(AppRoutes.LOGIN); 28 | } 29 | yield put(createResourceFetchFailAction(resourceName, action.params, error as Error)); 30 | } 31 | } 32 | 33 | return fetchResource; 34 | }; 35 | -------------------------------------------------------------------------------- /src/meridian/home/hooks/usePagination.ts: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { selectSettings } from 'meridian/settings'; 5 | import { selectTorrentFilters } from 'meridian/torrentFilters'; 6 | 7 | type PaginationResult = { 8 | numberOfPages: number; 9 | currentItems: T[]; 10 | page: number; 11 | setPage: (page: number) => void; 12 | }; 13 | 14 | const usePagination = (items: T[] | undefined, itemsPerPage: number): PaginationResult => { 15 | const [page, setPage] = useState(1); 16 | const torrentFilters = useSelector(selectTorrentFilters); 17 | const settings = useSelector(selectSettings); 18 | 19 | useEffect(() => { 20 | setPage(1); 21 | }, [torrentFilters, settings.torrentsPerPage]); 22 | 23 | if (!items) { 24 | return { 25 | numberOfPages: 0, 26 | currentItems: [], 27 | page, 28 | setPage, 29 | }; 30 | } 31 | 32 | const numberOfPages = 33 | Math.floor(items.length / itemsPerPage) + (items.length % itemsPerPage === 0 ? 0 : 1); 34 | const currentItems = items.slice((page - 1) * itemsPerPage, page * itemsPerPage); 35 | 36 | return { 37 | numberOfPages, 38 | currentItems, 39 | page, 40 | setPage, 41 | }; 42 | }; 43 | 44 | export default usePagination; 45 | -------------------------------------------------------------------------------- /src/meridian/resource/createDeleteResourceSaga.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-imports */ 2 | import { apply, put } from 'redux-saga/effects'; 3 | 4 | import { Api, ApiError } from 'meridian/api'; 5 | import { history } from 'meridian/navigation/history'; 6 | import { AppRoutes } from 'meridian/navigation/types'; 7 | 8 | import { 9 | ResourceDeleteAction, 10 | createResourceDeleteFailAction, 11 | createResourceDeleteSuccessAction, 12 | createResourceFetchAction, 13 | } from './createResourceReducer'; 14 | import { Resource } from './types'; 15 | 16 | export const createDeleteResourceSaga = (resourceName: T) => { 17 | function* deleteResource(action: ResourceDeleteAction) { 18 | const api = new Api(); 19 | try { 20 | yield apply(api, api.deleteResource, [resourceName, action.params]); 21 | yield put(createResourceDeleteSuccessAction(resourceName, action.params)); 22 | yield put(createResourceFetchAction(resourceName)); 23 | } catch (error) { 24 | const { status } = error as ApiError; 25 | if (status === 403) { 26 | history.replace(AppRoutes.LOGIN); 27 | } 28 | yield put(createResourceDeleteFailAction(resourceName, action.params, error as Error)); 29 | throw error; 30 | } 31 | } 32 | 33 | return deleteResource; 34 | }; 35 | -------------------------------------------------------------------------------- /src/meridian/tags/modals/useCreateTagModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Button, TextInput } from '@mantine/core'; 6 | import { useModals } from '@mantine/modals'; 7 | 8 | import { commonModalConfiguration } from 'meridian/generic'; 9 | import { useCloseLastModal, useCreateResource } from 'meridian/hooks'; 10 | import { Resource } from 'meridian/resource'; 11 | 12 | import useTagForm from '../useTagsForm'; 13 | 14 | const CreateTagModal = () => { 15 | const closeLastModal = useCloseLastModal(); 16 | const form = useTagForm(); 17 | const createTags = useCreateResource(Resource.TAGS); 18 | 19 | const onSubmit = ({ tagName }: { tagName: string }) => { 20 | createTags([tagName]); 21 | closeLastModal(); 22 | }; 23 | 24 | return ( 25 | 26 | 27 | {t`Submit`} 28 | 29 | ); 30 | }; 31 | 32 | const useCreateTagModal = () => { 33 | const modals = useModals(); 34 | 35 | return () => 36 | modals.openModal({ 37 | title: t`Create new tag`, 38 | children: , 39 | ...commonModalConfiguration, 40 | }); 41 | }; 42 | 43 | export default useCreateTagModal; 44 | -------------------------------------------------------------------------------- /src/meridian/state/sagas/startupSaga.ts: -------------------------------------------------------------------------------- 1 | import { apply, call, put } from 'redux-saga/effects'; 2 | 3 | import { LocalStorage, LocalStorageKey } from 'meridian/localStorage'; 4 | import { Settings, TorrentFilters } from 'meridian/models'; 5 | import { Resource, createResourceFetchAction } from 'meridian/resource'; 6 | import { createFetchVersionsAction } from 'meridian/session'; 7 | import { createSetSettingsAction } from 'meridian/settings'; 8 | import { createSetTorrentFiltersAction } from 'meridian/torrentFilters'; 9 | 10 | function* hydrateFromStorageSaga() { 11 | const settings: Settings = yield apply(LocalStorage, LocalStorage.getValue, [ 12 | LocalStorageKey.SETTINGS, 13 | ]); 14 | if (settings) { 15 | yield put(createSetSettingsAction(settings)); 16 | } 17 | const torrentFilters: TorrentFilters = yield apply(LocalStorage, LocalStorage.getValue, [ 18 | LocalStorageKey.TORRENT_FILTERS, 19 | ]); 20 | if (torrentFilters) { 21 | yield put(createSetTorrentFiltersAction(torrentFilters)); 22 | } 23 | } 24 | 25 | function* startupSaga() { 26 | yield call(hydrateFromStorageSaga); 27 | yield put(createResourceFetchAction(Resource.CATEGORIES)); 28 | yield put(createResourceFetchAction(Resource.TAGS)); 29 | yield put(createResourceFetchAction(Resource.PREFERENCES)); 30 | yield put(createFetchVersionsAction()); 31 | } 32 | 33 | export default startupSaga; 34 | -------------------------------------------------------------------------------- /src/meridian/utils.ts: -------------------------------------------------------------------------------- 1 | export const bytesToSize = (bytes: number, decimals = 2) => { 2 | if (bytes === 0) return '0 B'; 3 | 4 | const k = 1024; 5 | const dm = decimals < 0 ? 0 : decimals; 6 | const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 7 | 8 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 9 | 10 | return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; 11 | }; 12 | 13 | export const removeDuplicatesFromArray = (array: T[]) => Array.from(new Set(array)); 14 | 15 | export const truncateLongText = (text: string, maxLength = 30) => 16 | text.length < maxLength ? text : `${text.slice(0, maxLength - 3)}...`; 17 | 18 | export const calculateEtaString = (eta: number) => { 19 | if (eta < 60) { 20 | return eta.toString(); 21 | } 22 | 23 | if (eta < 3600) { 24 | const minutes = Math.floor(eta / 60); 25 | const seconds = eta % 60; 26 | return `${minutes}:${seconds}`; 27 | } 28 | 29 | const hours = Math.floor(eta / 3600); 30 | const remainingMinutes = Math.floor((eta - hours * 3600) / 60); 31 | const remainingSeconds = eta % 60; 32 | return `${hours}:${remainingMinutes}:${remainingSeconds}`; 33 | }; 34 | 35 | export const getKeyForRecordValue = ( 36 | record: Record, 37 | value: Y, 38 | ) => Object.entries(record).filter((x) => x[1] === value)[0][0]; 39 | -------------------------------------------------------------------------------- /src/meridian/generic/labelWithBadge.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Badge, Box, Text, createStyles } from '@mantine/core'; 4 | 5 | interface Props { 6 | label: string; 7 | text: string; 8 | color?: string; 9 | } 10 | 11 | const LabelWithBadge = ({ label, text, color }: Props) => { 12 | const styles = useStyles(color); 13 | const items = text.trim().split(','); 14 | 15 | return ( 16 | 17 | {label} 18 | {items.map((item) => ( 19 | 29 | {item} 30 | 31 | ))} 32 | 33 | ); 34 | }; 35 | 36 | const useStyles = (color: string | undefined) => 37 | createStyles((theme) => { 38 | const appliedColor = 39 | !color || Object.keys(theme.colors).includes(color) ? undefined : color; 40 | return { 41 | badge: { 42 | backgroundColor: appliedColor, 43 | }, 44 | }; 45 | })(); 46 | 47 | export default LabelWithBadge; 48 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/speed/speedSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Accordion } from '@mantine/core'; 6 | 7 | import { SectionProps } from '../types'; 8 | 9 | import AlternativeRateLimitsSection from './alternativeRateLimitsSection'; 10 | import GlobalRateLimitsSection from './globalRateLimitsSection'; 11 | import RateLimitsSettingsSection from './rateLimitsSettingsSection'; 12 | 13 | const SpeedSection = (props: SectionProps) => ( 14 | 15 | 16 | 17 | {t`Global Rate Limits`} 18 | 19 | 20 | 21 | {t`Alternative Rate Limits`} 22 | 23 | 24 | 25 | {t`Rate Limits Settings`} 26 | 27 | 28 | 29 | 30 | ); 31 | 32 | export default SpeedSection; 33 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/connection/connectionLimitsSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Accordion, NumberInput } from '@mantine/core'; 6 | 7 | import { SectionProps } from '../types'; 8 | 9 | const ConnectionLimitsSection = ({ preferences, updatePreferencesKey }: SectionProps) => ( 10 | 11 | updatePreferencesKey('max_connec', value as number)} 15 | /> 16 | updatePreferencesKey('max_connec_per_torrent', value as number)} 21 | /> 22 | updatePreferencesKey('max_uploads', value as number)} 27 | /> 28 | updatePreferencesKey('max_uploads_per_torrent', value as number)} 33 | /> 34 | 35 | ); 36 | 37 | export default ConnectionLimitsSection; 38 | -------------------------------------------------------------------------------- /src/meridian/categories/modals/useCreateCategoryModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Button, TextInput } from '@mantine/core'; 6 | import { useModals } from '@mantine/modals'; 7 | 8 | import { commonModalConfiguration } from 'meridian/generic'; 9 | import { useCloseLastModal, useCreateResource } from 'meridian/hooks'; 10 | import { Category } from 'meridian/models'; 11 | import { Resource } from 'meridian/resource'; 12 | 13 | import useCategoryForm from '../useCategoryForm'; 14 | 15 | const CreateCategoryModal = () => { 16 | const closeLastModal = useCloseLastModal(); 17 | const createCategory = useCreateResource(Resource.CATEGORIES); 18 | const form = useCategoryForm(); 19 | 20 | const onSubmit = (category: Category) => { 21 | createCategory({ category }); 22 | closeLastModal(); 23 | }; 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | {t`Submit`} 31 | 32 | 33 | ); 34 | }; 35 | 36 | const useCreateCategoryModal = () => { 37 | const modals = useModals(); 38 | 39 | return () => 40 | modals.openModal({ 41 | title: t`Create new category`, 42 | children: , 43 | ...commonModalConfiguration, 44 | }); 45 | }; 46 | 47 | export default useCreateCategoryModal; 48 | -------------------------------------------------------------------------------- /src/meridian/tags/state/saga.ts: -------------------------------------------------------------------------------- 1 | import { t } from '@lingui/macro'; 2 | import { call, put, takeLatest } from 'redux-saga/effects'; 3 | 4 | import { 5 | Resource, 6 | ResourceDeleteAction, 7 | ResourceSetAction, 8 | createDeleteResourceSaga, 9 | createFetchResourceSaga, 10 | createResourceFetchAction, 11 | createSetResourceSaga, 12 | } from 'meridian/resource'; 13 | import { showSnackbarAction } from 'meridian/snackbar'; 14 | 15 | import { TagsActions } from './reducer'; 16 | 17 | function* setTagsSaga(action: ResourceSetAction) { 18 | try { 19 | yield call(createSetResourceSaga(Resource.TAGS), action); 20 | yield put(createResourceFetchAction(Resource.TAGS)); 21 | yield put(showSnackbarAction(t`Tags added successfully!`, 'success', 2000)); 22 | } catch (error) { 23 | yield put(showSnackbarAction(t`Failed to add tags!`, 'error', 2000)); 24 | } 25 | } 26 | 27 | function* deleteTagsSaga(action: ResourceDeleteAction) { 28 | try { 29 | yield call(createDeleteResourceSaga(Resource.TAGS), action); 30 | yield put(createResourceFetchAction(Resource.TAGS)); 31 | yield put(showSnackbarAction(t`Tags deleted successfully!`, 'success', 2000)); 32 | } catch (error) { 33 | yield put(showSnackbarAction(t`Failed to delete tags!`, 'error', 2000)); 34 | } 35 | } 36 | 37 | function* tagsSaga() { 38 | yield takeLatest(TagsActions.FETCH, createFetchResourceSaga(Resource.TAGS)); 39 | yield takeLatest(TagsActions.POST, setTagsSaga); 40 | yield takeLatest(TagsActions.DELETE, deleteTagsSaga); 41 | } 42 | 43 | export default tagsSaga; 44 | -------------------------------------------------------------------------------- /src/meridian/torrent/modals/useDeleteTorrentsModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Button, Checkbox } from '@mantine/core'; 6 | import { useModals } from '@mantine/modals'; 7 | 8 | import { commonModalConfiguration } from 'meridian/generic'; 9 | import { useCloseLastModal } from 'meridian/hooks'; 10 | 11 | import { useDeleteTorrents } from '../hooks'; 12 | 13 | interface Props { 14 | hashes: string[]; 15 | } 16 | 17 | const DeleteTorrentsModal = ({ hashes }: Props) => { 18 | const closeLastModal = useCloseLastModal(); 19 | const [deleteFiles, setDeleteFiles] = useState(false); 20 | const deleteTorrents = useDeleteTorrents(); 21 | 22 | const onSubmit = useCallback(() => { 23 | deleteTorrents(hashes, deleteFiles); 24 | closeLastModal(); 25 | }, [closeLastModal, deleteFiles, deleteTorrents, hashes]); 26 | 27 | return ( 28 | <> 29 | setDeleteFiles(event.target.checked)} 33 | /> 34 | {t`Delete`} 35 | > 36 | ); 37 | }; 38 | 39 | const useDeleteTorrentsModal = () => { 40 | const modals = useModals(); 41 | 42 | return (hashes: string[]) => 43 | modals.openModal({ 44 | title: t`Delete torrents`, 45 | children: , 46 | ...commonModalConfiguration, 47 | }); 48 | }; 49 | 50 | export default useDeleteTorrentsModal; 51 | -------------------------------------------------------------------------------- /src/meridian/torrentProperties/modals/tabs/generalTab.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { ScrollArea, createStyles } from '@mantine/core'; 6 | 7 | import { LabelWithText } from 'meridian/generic'; 8 | import { TorrentProperties } from 'meridian/models'; 9 | import { bytesToSize, calculateEtaString } from 'meridian/utils'; 10 | 11 | type Props = Pick< 12 | TorrentProperties, 13 | | 'save_path' 14 | | 'total_size' 15 | | 'share_ratio' 16 | | 'creation_date' 17 | | 'created_by' 18 | | 'addition_date' 19 | | 'time_elapsed' 20 | | 'comment' 21 | >; 22 | 23 | const GeneralTab = (props: Props) => { 24 | const styles = useStyles(); 25 | const items = { 26 | [t`Save path`]: props.save_path, 27 | [t`Size`]: bytesToSize(props.total_size), 28 | [t`Share ratio`]: props.share_ratio.toFixed(2), 29 | [t`Time elapsed`]: calculateEtaString(props.time_elapsed), 30 | [t`Creation date`]: new Date(props.creation_date).toLocaleString(), 31 | [t`Addition date`]: new Date(props.addition_date).toLocaleString(), 32 | [t`Comment`]: props.comment, 33 | [t`Created by`]: props.created_by, 34 | }; 35 | 36 | return ( 37 | 38 | {Object.entries(items).map(([label, text], index) => ( 39 | 40 | ))} 41 | 42 | ); 43 | }; 44 | 45 | const useStyles = createStyles({ 46 | scroll: { 47 | height: '50vh', 48 | }, 49 | }); 50 | 51 | export default memo(GeneralTab); 52 | -------------------------------------------------------------------------------- /src/meridian/categories/modals/useEditCategoryModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Button, TextInput } from '@mantine/core'; 6 | import { useModals } from '@mantine/modals'; 7 | 8 | import { commonModalConfiguration } from 'meridian/generic'; 9 | import { useCloseLastModal, useCreateResource } from 'meridian/hooks'; 10 | import { Category } from 'meridian/models'; 11 | import { Resource } from 'meridian/resource'; 12 | 13 | import useCategoryForm from '../useCategoryForm'; 14 | 15 | interface Props { 16 | category: Category; 17 | } 18 | 19 | const EditCategoryModal = ({ category: categoryToEdit }: Props) => { 20 | const closeLastModal = useCloseLastModal(); 21 | const createCategory = useCreateResource(Resource.CATEGORIES); 22 | const form = useCategoryForm(categoryToEdit); 23 | 24 | const onSubmit = (category: Category) => { 25 | createCategory({ category, editExisting: true }); 26 | closeLastModal(); 27 | }; 28 | 29 | return ( 30 | 31 | 32 | 33 | {t`Submit`} 34 | 35 | 36 | ); 37 | }; 38 | 39 | const useEditCategoryModal = () => { 40 | const modals = useModals(); 41 | 42 | return (category: Category) => 43 | modals.openModal({ 44 | title: `${t`Edit`} ${category.name}`, 45 | children: , 46 | ...commonModalConfiguration, 47 | }); 48 | }; 49 | 50 | export default useEditCategoryModal; 51 | -------------------------------------------------------------------------------- /src/meridian/torrent/modals/useTorrentCategoryModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { t } from '@lingui/macro'; 5 | 6 | import { LoadingOverlay, Select } from '@mantine/core'; 7 | import { useModals } from '@mantine/modals'; 8 | 9 | import { selectCategories } from 'meridian/categories'; 10 | import { commonModalConfiguration } from 'meridian/generic'; 11 | import { selectMainData } from 'meridian/mainData'; 12 | 13 | import { useSetTorrentCategory } from '../hooks'; 14 | 15 | interface Props { 16 | hash: string; 17 | } 18 | 19 | const TorrentCategoryModal = ({ hash }: Props) => { 20 | const categories = useSelector(selectCategories); 21 | const mainData = useSelector(selectMainData); 22 | const setTorrentCategory = useSetTorrentCategory(); 23 | 24 | if (!categories || !mainData) { 25 | return ; 26 | } 27 | 28 | const torrents = Object.values(mainData.torrents); 29 | const torrent = torrents.filter((x) => x.hash === hash)[0]; 30 | 31 | return ( 32 | setTorrentCategory([torrent.hash], category ?? '')} 38 | /> 39 | ); 40 | }; 41 | 42 | const useTorrentCategoryModal = () => { 43 | const modals = useModals(); 44 | 45 | return (hash: string, name: string) => 46 | modals.openModal({ 47 | title: name, 48 | children: , 49 | ...commonModalConfiguration, 50 | }); 51 | }; 52 | 53 | export default useTorrentCategoryModal; 54 | -------------------------------------------------------------------------------- /src/meridian/useAboutModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { Anchor, Avatar, Box, Text, createStyles } from '@mantine/core'; 5 | import { useModals } from '@mantine/modals'; 6 | 7 | import { commonModalConfiguration } from './generic'; 8 | import { Icons } from './icons'; 9 | import { getVersion } from './importMetaUtils'; 10 | import { selectVersions } from './session'; 11 | 12 | const AboutModal = () => { 13 | const styles = useStyles(); 14 | const versions = useSelector(selectVersions); 15 | 16 | return ( 17 | 18 | 19 | 20 | Version {getVersion()} 21 | 22 | href='https://www.qbittorrent.org/' mt='lg' weight={700}> 23 | QBitTorrent {versions.version} API {versions.apiVersion} 24 | 25 | 26 | Powered by{' '} 27 | href='https://mantine.dev/' weight={700}> 28 | Mantine 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | const useAboutModal = () => { 36 | const modals = useModals(); 37 | 38 | return () => 39 | modals.openModal({ 40 | children: , 41 | ...commonModalConfiguration, 42 | }); 43 | }; 44 | 45 | const useStyles = createStyles({ 46 | logo: { 47 | display: 'flex', 48 | flexDirection: 'column', 49 | alignItems: 'center', 50 | justifyContent: 'center', 51 | }, 52 | }); 53 | 54 | export default useAboutModal; 55 | -------------------------------------------------------------------------------- /src/meridian/categories/state/saga.ts: -------------------------------------------------------------------------------- 1 | import { t } from '@lingui/macro'; 2 | import { call, put, takeLatest } from 'redux-saga/effects'; 3 | 4 | import { 5 | Resource, 6 | ResourceDeleteAction, 7 | ResourceSetAction, 8 | createDeleteResourceSaga, 9 | createFetchResourceSaga, 10 | createResourceFetchAction, 11 | createSetResourceSaga, 12 | } from 'meridian/resource'; 13 | import { showSnackbarAction } from 'meridian/snackbar'; 14 | 15 | import { CategoriesActions } from './reducer'; 16 | 17 | function* setCategoriesSaga(action: ResourceSetAction) { 18 | try { 19 | yield call(createSetResourceSaga(Resource.CATEGORIES), action); 20 | yield put(createResourceFetchAction(Resource.CATEGORIES)); 21 | yield put(showSnackbarAction(t`Categories added successfully!`, 'success', 2000)); 22 | } catch (error) { 23 | yield put(showSnackbarAction(t`Failed to add categories!`, 'success', 2000)); 24 | } 25 | } 26 | 27 | function* deleteCategoriesSaga(action: ResourceDeleteAction) { 28 | try { 29 | yield call(createDeleteResourceSaga(Resource.CATEGORIES), action); 30 | yield put(createResourceFetchAction(Resource.CATEGORIES)); 31 | yield put(showSnackbarAction(t`Categories deleted successfully!`, 'success', 2000)); 32 | } catch (error) { 33 | yield put(showSnackbarAction(t`Failed to delete categories!`, 'error', 2000)); 34 | } 35 | } 36 | 37 | function* categoriesSaga() { 38 | yield takeLatest(CategoriesActions.FETCH, createFetchResourceSaga(Resource.CATEGORIES)); 39 | yield takeLatest(CategoriesActions.POST, setCategoriesSaga); 40 | yield takeLatest(CategoriesActions.DELETE, deleteCategoriesSaga); 41 | } 42 | 43 | export default categoriesSaga; 44 | -------------------------------------------------------------------------------- /src/meridian/session/state/actions.ts: -------------------------------------------------------------------------------- 1 | import { BaseAction } from 'meridian/resource'; 2 | 3 | export enum SessionActions { 4 | LOGIN = 'SESSION/LOGIN', 5 | LOGIN_SUCCESS = 'SESSION/LOGIN_SUCCESS', 6 | LOGIN_FAIL = 'SESSION/LOGIN_FAIL', 7 | LOGOUT = 'SESSION/LOGOUT', 8 | LOGOUT_SUCCESS = 'SESSION/LOGOUT_SUCCESS', 9 | LOGOUT_FAIL = 'SESSION/LOGOUT_FAIL', 10 | FETCH_VERSIONS = 'SESSION/FETCH_VERSIONS', 11 | SET_VERSIONS = 'SESSION/SET_VERSIONS', 12 | } 13 | 14 | export type LoginAction = BaseAction & { 15 | username: string; 16 | password: string; 17 | }; 18 | 19 | export type SetVersionsAction = BaseAction & { 20 | version: string; 21 | apiVersion: string; 22 | }; 23 | 24 | export const loginAction = (username: string, password: string): LoginAction => ({ 25 | type: SessionActions.LOGIN, 26 | username, 27 | password, 28 | }); 29 | 30 | export const loginSuccessAction = (): BaseAction => ({ 31 | type: SessionActions.LOGIN_SUCCESS, 32 | }); 33 | 34 | export const loginFailAction = (): BaseAction => ({ 35 | type: SessionActions.LOGIN_FAIL, 36 | }); 37 | 38 | export const logoutAction = (): BaseAction => ({ 39 | type: SessionActions.LOGOUT, 40 | }); 41 | 42 | export const logoutSuccessAction = (): BaseAction => ({ 43 | type: SessionActions.LOGOUT_SUCCESS, 44 | }); 45 | 46 | export const logoutFailAction = (): BaseAction => ({ 47 | type: SessionActions.LOGOUT_FAIL, 48 | }); 49 | 50 | export const createFetchVersionsAction = (): BaseAction => ({ 51 | type: SessionActions.FETCH_VERSIONS, 52 | }); 53 | 54 | export const createSetVersionsAction = ( 55 | version: string, 56 | apiVersion: string, 57 | ): SetVersionsAction => ({ 58 | type: SessionActions.SET_VERSIONS, 59 | version, 60 | apiVersion, 61 | }); 62 | -------------------------------------------------------------------------------- /src/meridian/generic/__tests__/__snapshots__/labelWithText.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`LabelWithText should render as expected 1`] = ` 4 | 5 | .emotion-0 { 6 | display: -webkit-box; 7 | display: -webkit-flex; 8 | display: -ms-flexbox; 9 | display: flex; 10 | -webkit-flex-direction: row; 11 | -ms-flex-direction: row; 12 | flex-direction: row; 13 | -webkit-align-items: center; 14 | -webkit-box-align: center; 15 | -ms-flex-align: center; 16 | align-items: center; 17 | } 18 | 19 | .emotion-3 { 20 | font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji; 21 | -webkit-tap-highlight-color: transparent; 22 | color: inherit; 23 | font-size: inherit; 24 | line-height: 1.55; 25 | -webkit-text-decoration: none; 26 | text-decoration: none; 27 | } 28 | 29 | .emotion-3:focus { 30 | outline-offset: 2px; 31 | outline: 2px solid #339af0; 32 | } 33 | 34 | .emotion-3:focus:not(:focus-visible) { 35 | outline: none; 36 | } 37 | 38 | .emotion-5 { 39 | font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji; 40 | -webkit-tap-highlight-color: transparent; 41 | color: #868e96; 42 | font-size: 14px; 43 | line-height: 1.55; 44 | -webkit-text-decoration: none; 45 | text-decoration: none; 46 | } 47 | 48 | .emotion-5:focus { 49 | outline-offset: 2px; 50 | outline: 2px solid #339af0; 51 | } 52 | 53 | .emotion-5:focus:not(:focus-visible) { 54 | outline: none; 55 | } 56 | 57 | 60 | 63 | 66 | Something 67 | 68 | 71 | Some text 72 | 73 | 74 | 75 | 76 | `; 77 | -------------------------------------------------------------------------------- /src/meridian/home/hooks/useFilteredTorrents.ts: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { selectMainData } from 'meridian/mainData'; 5 | import { getTorrentStateDescription } from 'meridian/models'; 6 | import { selectTorrentFilters } from 'meridian/torrentFilters'; 7 | 8 | const useFilteredTorrents = () => { 9 | const mainData = useSelector(selectMainData); 10 | const torrentFilters = useSelector(selectTorrentFilters); 11 | 12 | return useMemo(() => { 13 | if (!mainData?.torrents) { 14 | return undefined; 15 | } 16 | let filteredTorrents = Object.values(mainData.torrents); 17 | if (torrentFilters.name.trim() !== '') { 18 | filteredTorrents = filteredTorrents.filter((torrent) => 19 | torrent.name.toLowerCase().includes(torrentFilters.name.trim().toLowerCase()), 20 | ); 21 | } 22 | 23 | if (torrentFilters.states.length) { 24 | filteredTorrents = filteredTorrents.filter((torrent) => 25 | torrentFilters.states.includes(getTorrentStateDescription(torrent.state)), 26 | ); 27 | } 28 | 29 | if (torrentFilters?.categories.length) { 30 | filteredTorrents = filteredTorrents.filter((torrent) => 31 | torrentFilters.categories.includes(torrent.category), 32 | ); 33 | } 34 | 35 | if (torrentFilters?.tags.length) { 36 | filteredTorrents = filteredTorrents.filter((torrent) => { 37 | const torrentTags = torrent.tags.split(','); 38 | if (!torrentTags.length) { 39 | return false; 40 | } 41 | 42 | return torrentTags.some((tag) => torrentFilters.tags.includes(tag)); 43 | }); 44 | } 45 | 46 | return filteredTorrents; 47 | }, [mainData, torrentFilters]); 48 | }; 49 | 50 | export default useFilteredTorrents; 51 | -------------------------------------------------------------------------------- /src/meridian/i18n/languagePicker.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { t } from '@lingui/macro'; 5 | 6 | import { Avatar, Group, Select, Text } from '@mantine/core'; 7 | 8 | import { createSetSettingsAction, selectSettings } from 'meridian/settings'; 9 | 10 | import { Language, LanguageIcon, LanguageName } from './types'; 11 | 12 | interface ItemProps { 13 | icon: string; 14 | label: string; 15 | value: string; 16 | } 17 | 18 | const getLanguageItems = (): ItemProps[] => { 19 | const languages = Object.values(Language); 20 | return languages.map((language) => ({ 21 | icon: LanguageIcon[language], 22 | label: LanguageName[language], 23 | value: language, 24 | })); 25 | }; 26 | 27 | // eslint-disable-next-line react/display-name 28 | const SelectItem = forwardRef( 29 | ({ icon, label, ...others }: ItemProps, ref) => ( 30 | 31 | 32 | 33 | {label} 34 | 35 | 36 | ), 37 | ); 38 | 39 | const LanguagePicker = () => { 40 | const settings = useSelector(selectSettings); 41 | const dispatch = useDispatch(); 42 | const data = getLanguageItems(); 43 | 44 | const onChange = (value: string) => { 45 | dispatch( 46 | createSetSettingsAction({ 47 | ...settings, 48 | language: value as Language, 49 | }), 50 | ); 51 | }; 52 | 53 | return ( 54 | } 57 | value={settings.language} 58 | itemComponent={SelectItem} 59 | data={data} 60 | onChange={onChange} 61 | /> 62 | ); 63 | }; 64 | 65 | export default LanguagePicker; 66 | -------------------------------------------------------------------------------- /src/meridian/tags/modals/useTagsModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { t } from '@lingui/macro'; 5 | import { Trash } from 'tabler-icons-react'; 6 | 7 | import { ActionIcon, Box, Button, Group, LoadingOverlay, Text, createStyles } from '@mantine/core'; 8 | import { useModals } from '@mantine/modals'; 9 | 10 | import { commonModalConfiguration } from 'meridian/generic'; 11 | import { useDeleteResource } from 'meridian/hooks'; 12 | import { Resource } from 'meridian/resource'; 13 | 14 | import { selectTags } from '../state'; 15 | 16 | import useCreateTagModal from './useCreateTagModal'; 17 | 18 | const TagsModal = () => { 19 | const styles = useStyles(); 20 | const tags = useSelector(selectTags); 21 | const openCreateTagModal = useCreateTagModal(); 22 | const deleteTags = useDeleteResource(Resource.TAGS); 23 | 24 | if (!tags) { 25 | return ; 26 | } 27 | 28 | const createOnDeleteClickHandler = (tag: string) => () => deleteTags([tag]); 29 | 30 | return ( 31 | <> 32 | {tags.map((tag, key) => ( 33 | 34 | {tag} 35 | 36 | 37 | 38 | 39 | 40 | ))} 41 | {t`New`} 42 | > 43 | ); 44 | }; 45 | 46 | const useTagsModal = () => { 47 | const modals = useModals(); 48 | 49 | return () => 50 | modals.openModal({ 51 | title: t`Tags`, 52 | children: , 53 | ...commonModalConfiguration, 54 | }); 55 | }; 56 | 57 | const useStyles = createStyles({ 58 | space: { 59 | flexGrow: 1, 60 | backgroundColor: 'transparent', // does not flex without this? 61 | }, 62 | }); 63 | 64 | export default useTagsModal; 65 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/bitTorrent/privacySection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Accordion, Select, Switch } from '@mantine/core'; 6 | 7 | import { BitTorrentEncryptionNameMapping } from 'meridian/models'; 8 | import { getKeyForRecordValue } from 'meridian/utils'; 9 | 10 | import { SectionProps } from '../types'; 11 | 12 | const PrivacySection = ({ preferences, updatePreferencesKey }: SectionProps) => ( 13 | 14 | updatePreferencesKey('dht', value.target.checked)} 18 | /> 19 | updatePreferencesKey('pex', value.target.checked)} 24 | /> 25 | updatePreferencesKey('lsd', value.target.checked)} 30 | /> 31 | 37 | updatePreferencesKey( 38 | 'encryption', 39 | getKeyForRecordValue(BitTorrentEncryptionNameMapping, value), 40 | ) 41 | } 42 | /> 43 | updatePreferencesKey('anonymous_mode', value.target.checked)} 48 | /> 49 | 50 | ); 51 | 52 | export default PrivacySection; 53 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/bitTorrent/bitTorrentSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Accordion, Switch, Textarea } from '@mantine/core'; 6 | 7 | import { SectionProps } from '../types'; 8 | 9 | import PrivacySection from './privacySection'; 10 | import QueueingSection from './queueingSection'; 11 | import SeedingLimitsSection from './seedingLimitsSection'; 12 | 13 | const BitTorrentSection = (props: SectionProps) => { 14 | const { preferences, updatePreferencesKey } = props; 15 | return ( 16 | 17 | 18 | 19 | {t`Privacy`} 20 | 21 | 22 | 23 | {t`Torrent Queueing`} 24 | 25 | 26 | 27 | {t`Seeding Limits`} 28 | 29 | 30 | 31 | 36 | updatePreferencesKey('add_trackers_enabled', value.target.checked) 37 | } 38 | /> 39 | updatePreferencesKey('add_trackers', value.target.value)} 44 | /> 45 | 46 | ); 47 | }; 48 | 49 | export default BitTorrentSection; 50 | -------------------------------------------------------------------------------- /src/meridian/torrent/modals/useTorrentTagsModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { LoadingOverlay, Switch } from '@mantine/core'; 5 | import { useModals } from '@mantine/modals'; 6 | 7 | import { commonModalConfiguration } from 'meridian/generic'; 8 | import { selectMainData } from 'meridian/mainData'; 9 | import { selectTags } from 'meridian/tags'; 10 | 11 | import { useManageTorrentTags } from '../hooks'; 12 | 13 | interface Props { 14 | hash: string; 15 | } 16 | 17 | const TorrentTagsModal = ({ hash }: Props) => { 18 | const tags = useSelector(selectTags); 19 | const mainData = useSelector(selectMainData); 20 | const { addTags, removeTags } = useManageTorrentTags(); 21 | 22 | if (!tags || !mainData) { 23 | return ; 24 | } 25 | 26 | const torrents = Object.values(mainData.torrents); 27 | const torrent = torrents.filter((x) => x.hash === hash)[0]; 28 | 29 | const torrentTags = torrent.tags === '' ? [] : torrent.tags.split(',').map((x) => x.trim()); 30 | 31 | const createOnChangeHandler = (tag: string, event: React.ChangeEvent) => { 32 | if (event.target.checked) { 33 | addTags([torrent.hash], [tag]); 34 | } else { 35 | removeTags([torrent.hash], [tag]); 36 | } 37 | }; 38 | 39 | return ( 40 | <> 41 | {tags.map((tag, key) => ( 42 | createOnChangeHandler(tag, event)} 48 | /> 49 | ))} 50 | > 51 | ); 52 | }; 53 | 54 | const useTorrentTagsModal = () => { 55 | const modals = useModals(); 56 | 57 | return (hash: string, name: string) => 58 | modals.openModal({ 59 | title: name, 60 | children: , 61 | ...commonModalConfiguration, 62 | }); 63 | }; 64 | 65 | export default useTorrentTagsModal; 66 | -------------------------------------------------------------------------------- /src/meridian/settings/state/reducer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line no-restricted-imports */ 2 | import { Language } from 'meridian/i18n/types'; 3 | import { LocalStorage, LocalStorageKey } from 'meridian/localStorage'; 4 | import { Settings } from 'meridian/models'; 5 | import { BaseAction } from 'meridian/resource'; 6 | 7 | import { SetSettingsAction, SettingsActions } from './actions'; 8 | import { SettingsState } from './types'; 9 | 10 | export const defaultSettings: Settings = { 11 | darkMode: true, 12 | autoRefresh: true, 13 | autoRefreshInterval: 5, 14 | torrentsPerPage: 5, 15 | language: Language.ENGLISH, 16 | }; 17 | 18 | const initialState: SettingsState = { 19 | settings: defaultSettings, 20 | }; 21 | 22 | export const settingsReducer = ( 23 | state: SettingsState = initialState, 24 | action: BaseAction, 25 | ): SettingsState => { 26 | switch (action.type) { 27 | case SettingsActions.SET_SETTINGS: { 28 | const successAction = action as SetSettingsAction; 29 | LocalStorage.setValue(LocalStorageKey.SETTINGS, successAction.settings); 30 | return { 31 | ...state, 32 | settings: { 33 | darkMode: 34 | successAction.settings.darkMode !== undefined 35 | ? successAction.settings.darkMode 36 | : defaultSettings.darkMode, 37 | autoRefresh: 38 | successAction.settings.autoRefresh !== undefined 39 | ? successAction.settings.autoRefresh 40 | : defaultSettings.autoRefresh, 41 | autoRefreshInterval: 42 | successAction.settings.autoRefreshInterval || 43 | defaultSettings.autoRefreshInterval, 44 | torrentsPerPage: 45 | successAction.settings.torrentsPerPage || defaultSettings.torrentsPerPage, 46 | language: successAction.settings.language || defaultSettings.language, 47 | }, 48 | }; 49 | } 50 | default: { 51 | return state; 52 | } 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /src/meridian/home/hooks/useHeaderMenuItems.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | import { 5 | BoxMultiple, 6 | DeviceDesktop, 7 | Edit, 8 | InfoCircle, 9 | Settings, 10 | Tag, 11 | User, 12 | } from 'tabler-icons-react'; 13 | 14 | import { useCategoriesModal } from 'meridian/categories'; 15 | import { ContextMenuItem } from 'meridian/generic'; 16 | import { useLogout } from 'meridian/hooks'; 17 | import { useServerStateModal } from 'meridian/mainData'; 18 | import { usePreferencesModal } from 'meridian/preferences'; 19 | import { useSettingsModal } from 'meridian/settings'; 20 | import { useTagsModal } from 'meridian/tags'; 21 | import useAboutModal from 'meridian/useAboutModal'; 22 | 23 | const useHeaderMenuItems = (): ContextMenuItem[] => { 24 | const openSettingsModal = useSettingsModal(); 25 | const openPreferencesModal = usePreferencesModal(); 26 | const openCategoriesModal = useCategoriesModal(); 27 | const openTagsModal = useTagsModal(); 28 | const openServerStateModal = useServerStateModal(); 29 | const openAboutModal = useAboutModal(); 30 | const logout = useLogout(); 31 | return [ 32 | { 33 | text: t`WebUI Settings`, 34 | icon: , 35 | callback: openSettingsModal, 36 | }, 37 | { 38 | text: t`Preferences`, 39 | icon: , 40 | callback: openPreferencesModal, 41 | }, 42 | { 43 | text: t`Categories`, 44 | icon: , 45 | callback: openCategoriesModal, 46 | }, 47 | { 48 | text: t`Tags`, 49 | icon: , 50 | callback: openTagsModal, 51 | }, 52 | { 53 | text: t`Server state`, 54 | icon: , 55 | callback: openServerStateModal, 56 | }, 57 | { 58 | text: t`About`, 59 | icon: , 60 | callback: openAboutModal, 61 | }, 62 | { 63 | text: t`Logout`, 64 | icon: , 65 | callback: logout, 66 | }, 67 | ]; 68 | }; 69 | 70 | export default useHeaderMenuItems; 71 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/webui/securitySection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Accordion, Switch, Textarea } from '@mantine/core'; 6 | 7 | import { SectionProps } from '../types'; 8 | 9 | const SecuritySection = ({ preferences, updatePreferencesKey }: SectionProps) => ( 10 | 11 | 15 | updatePreferencesKey('web_ui_clickjacking_protection_enabled', value.target.checked) 16 | } 17 | /> 18 | 23 | updatePreferencesKey('web_ui_csrf_protection_enabled', value.target.checked) 24 | } 25 | /> 26 | 32 | updatePreferencesKey('web_ui_secure_cookie_enabled', value.target.checked) 33 | } 34 | /> 35 | 40 | updatePreferencesKey('web_ui_host_header_validation_enabled', value.target.checked) 41 | } 42 | /> 43 | updatePreferencesKey('web_ui_domain_list', value.target.value)} 49 | /> 50 | 51 | ); 52 | 53 | export default SecuritySection; 54 | -------------------------------------------------------------------------------- /src/meridian/settings/modals/useSettingsModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Select, Switch } from '@mantine/core'; 6 | import { useModals } from '@mantine/modals'; 7 | 8 | import { commonModalConfiguration } from 'meridian/generic'; 9 | import { LanguagePicker } from 'meridian/i18n'; 10 | 11 | import useSettings from './useSettings'; 12 | 13 | const SettingsModal = () => { 14 | const { settings, handleSettingsChange } = useSettings(); 15 | 16 | const handlers = { 17 | torrentsPerPage: (value: string) => handleSettingsChange('torrentsPerPage', Number(value)), 18 | autoRefresh: (event: React.ChangeEvent) => 19 | handleSettingsChange('autoRefresh', event.target.checked), 20 | autoRefreshInterval: (value: string) => 21 | handleSettingsChange('autoRefreshInterval', Number(value)), 22 | }; 23 | 24 | return ( 25 | <> 26 | 27 | x.toString())} 32 | onChange={handlers['torrentsPerPage']} 33 | /> 34 | 40 | x.toString())} 46 | onChange={handlers['autoRefreshInterval']} 47 | /> 48 | > 49 | ); 50 | }; 51 | 52 | const useSettingsModal = () => { 53 | const modals = useModals(); 54 | 55 | return () => 56 | modals.openModal({ 57 | title: t`WebUI Settings`, 58 | children: , 59 | ...commonModalConfiguration, 60 | }); 61 | }; 62 | 63 | export default useSettingsModal; 64 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/bitTorrent/seedingLimitsSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Accordion, NumberInput, Select, Switch } from '@mantine/core'; 6 | 7 | import { BitTorrentMaxRatioActNameMaping } from 'meridian/models'; 8 | import { getKeyForRecordValue } from 'meridian/utils'; 9 | 10 | import { SectionProps } from '../types'; 11 | 12 | const SeedingLimitsSection = ({ preferences, updatePreferencesKey }: SectionProps) => ( 13 | 14 | updatePreferencesKey('max_ratio_enabled', value.target.checked)} 18 | /> 19 | updatePreferencesKey('max_ratio', value as number)} 24 | /> 25 | 30 | updatePreferencesKey('max_seeding_time_enabled', value.target.checked) 31 | } 32 | /> 33 | updatePreferencesKey('max_seeding_time', value as number)} 38 | /> 39 | 46 | updatePreferencesKey( 47 | 'max_ratio_act', 48 | getKeyForRecordValue(BitTorrentMaxRatioActNameMaping, value), 49 | ) 50 | } 51 | /> 52 | 53 | ); 54 | 55 | export default SeedingLimitsSection; 56 | -------------------------------------------------------------------------------- /src/meridian/generic/drawerPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { Menu2 } from 'tabler-icons-react'; 4 | 5 | import { ActionIcon, Avatar, Box, Drawer, Header, createStyles } from '@mantine/core'; 6 | 7 | import { Icons } from 'meridian/icons'; 8 | 9 | import { ColorSchemeToggle } from './colorSchemeToggle'; 10 | 11 | interface Props { 12 | children: React.ReactNode; 13 | drawerContent?: React.ReactNode; 14 | headerLeftContent?: React.ReactNode; 15 | headerRightContent?: React.ReactNode; 16 | } 17 | 18 | const DrawerPage = ({ children, drawerContent, headerLeftContent, headerRightContent }: Props) => { 19 | const styles = useStyles(); 20 | const [opened, setOpened] = useState(false); 21 | 22 | return ( 23 | 24 | setOpened(false)} 28 | title={} 29 | padding='xl' 30 | size='sm' 31 | overlayBlur={5} 32 | > 33 | {drawerContent} 34 | 35 | 36 | setOpened(true)}> 37 | 38 | 39 | {headerLeftContent} 40 | 41 | 42 | {headerRightContent} 43 | 44 | {children} 45 | 46 | ); 47 | }; 48 | 49 | const useStyles = createStyles((theme) => ({ 50 | header: { 51 | display: 'flex', 52 | alignItems: 'center', 53 | paddingLeft: 10, 54 | paddingRight: 10, 55 | position: 'sticky', 56 | }, 57 | drawer: { 58 | display: 'flex', 59 | flexDirection: 'column', 60 | flex: 1, 61 | }, 62 | space: { 63 | flexGrow: 1, 64 | }, 65 | content: { 66 | backgroundColor: theme.colorScheme === 'light' ? theme.white : theme.colors.dark[5], 67 | flex: 1, 68 | display: 'flex', 69 | flexDirection: 'column', 70 | }, 71 | })); 72 | 73 | export default DrawerPage; 74 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/connection/connectionSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Accordion, Select } from '@mantine/core'; 6 | 7 | import { BittorrentProtocolNameMapping } from 'meridian/models'; 8 | import { getKeyForRecordValue } from 'meridian/utils'; 9 | 10 | import { SectionProps } from '../types'; 11 | 12 | import ConnectionLimitsSection from './connectionLimitsSection'; 13 | import IpFilteringSection from './ipFilteringSection'; 14 | import ListeningPortSection from './listeningPortSection'; 15 | import ProxyServerSection from './proxyServerSection'; 16 | 17 | const ConnectionSection = (props: SectionProps) => { 18 | const { preferences, updatePreferencesKey } = props; 19 | return ( 20 | 21 | 26 | updatePreferencesKey( 27 | 'bittorrent_protocol', 28 | getKeyForRecordValue(BittorrentProtocolNameMapping, value), 29 | ) 30 | } 31 | /> 32 | 33 | 34 | {t`Listening Port`} 35 | 36 | 37 | 38 | {t`Connection Limits`} 39 | 40 | 41 | 42 | {t`Proxy Server`} 43 | 44 | 45 | 46 | {t`IP Filtering`} 47 | 48 | 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default ConnectionSection; 55 | -------------------------------------------------------------------------------- /src/meridian/generic/__tests__/__snapshots__/labelWithBadge.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`LabelWithBadge should render as expected 1`] = ` 4 | 5 | .emotion-2 { 6 | font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji; 7 | -webkit-tap-highlight-color: transparent; 8 | color: inherit; 9 | font-size: inherit; 10 | line-height: 1.55; 11 | -webkit-text-decoration: none; 12 | text-decoration: none; 13 | } 14 | 15 | .emotion-2:focus { 16 | outline-offset: 2px; 17 | outline: 2px solid #339af0; 18 | } 19 | 20 | .emotion-2:focus:not(:focus-visible) { 21 | outline: none; 22 | } 23 | 24 | .emotion-4 { 25 | -webkit-tap-highlight-color: transparent; 26 | font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji; 27 | font-size: 11px; 28 | height: 20px; 29 | line-height: 18px; 30 | -webkit-text-decoration: none; 31 | text-decoration: none; 32 | padding: 0 10.666666666666666px; 33 | box-sizing: border-box; 34 | display: -webkit-inline-box; 35 | display: -webkit-inline-flex; 36 | display: -ms-inline-flexbox; 37 | display: inline-flex; 38 | -webkit-align-items: center; 39 | -webkit-box-align: center; 40 | -ms-flex-align: center; 41 | align-items: center; 42 | -webkit-box-pack: center; 43 | -ms-flex-pack: center; 44 | -webkit-justify-content: center; 45 | justify-content: center; 46 | width: auto; 47 | text-transform: uppercase; 48 | border-radius: 32px; 49 | font-weight: 700; 50 | letter-spacing: 0.25px; 51 | cursor: inherit; 52 | text-overflow: ellipsis; 53 | overflow: hidden; 54 | background: rgba(231, 245, 255, 1); 55 | color: #228be6; 56 | border: 1px solid transparent; 57 | background-color: #FFFFF; 58 | margin-right: 5px; 59 | } 60 | 61 | .emotion-4:focus { 62 | outline-offset: 2px; 63 | outline: 2px solid #339af0; 64 | } 65 | 66 | .emotion-4:focus:not(:focus-visible) { 67 | outline: none; 68 | } 69 | 70 | .emotion-5 { 71 | white-space: nowrap; 72 | overflow: hidden; 73 | text-overflow: ellipsis; 74 | } 75 | 76 | 79 | 82 | Something 83 | 84 | 87 | 90 | Some text 91 | 92 | 93 | 94 | 95 | `; 96 | -------------------------------------------------------------------------------- /src/meridian/torrent/card/card.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Card, createStyles } from '@mantine/core'; 4 | 5 | import { TorrentInfo } from 'meridian/models'; 6 | 7 | import ConnectionInfo from './connectionInfo'; 8 | import FileInfo from './fileInfo'; 9 | import ProgressIndicator from './progressIndicator'; 10 | import StatusInfo from './statusInfo'; 11 | import TitleAndMenu from './titleAndMenu'; 12 | 13 | interface Props { 14 | torrent: TorrentInfo; 15 | selectable: boolean; 16 | selected: boolean; 17 | onSelectionChanged?: (hash: string, selected: boolean) => void; 18 | } 19 | 20 | const TorrentCard = ({ torrent, selectable, selected, onSelectionChanged }: Props) => { 21 | const styles = useStyles(selectable, selected); 22 | 23 | const toggleSelection = () => onSelectionChanged?.(torrent.hash, !selected); 24 | 25 | return ( 26 | 34 | 35 | 43 | 44 | 52 | 53 | 54 | ); 55 | }; 56 | 57 | const useStyles = (selectable: boolean, selected: boolean) => 58 | createStyles((theme) => ({ 59 | root: { 60 | display: 'flex', 61 | flexDirection: 'column', 62 | borderLeftColor: selectable && selected ? theme.colors.blue : undefined, 63 | borderLeftWidth: 10, 64 | cursor: selectable ? 'pointer' : 'default', 65 | '@media (min-width: 1000px)': { 66 | width: 1000, 67 | alignSelf: 'center', 68 | }, 69 | }, 70 | }))(); 71 | 72 | export default TorrentCard; 73 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/usePreferencesModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Accordion, Button } from '@mantine/core'; 6 | import { useModals } from '@mantine/modals'; 7 | 8 | import { commonModalConfiguration } from 'meridian/generic'; 9 | 10 | import { 11 | BitTorrentSection, 12 | ConnectionSection, 13 | DownloadsSection, 14 | SpeedSection, 15 | WebUiSection, 16 | } from './sections'; 17 | import usePreferences from './usePreferences'; 18 | 19 | const PreferencesModal = () => { 20 | const { onSave, preferences, updatePreferencesKey, updateBulkPreferencesKey } = 21 | usePreferences(); 22 | 23 | const sectionProps = { 24 | preferences, 25 | updatePreferencesKey, 26 | updateBulkPreferencesKey, 27 | }; 28 | 29 | return ( 30 | <> 31 | 32 | 33 | {t`Downloads`} 34 | 35 | 36 | 37 | {t`Connection`} 38 | 39 | 40 | 41 | {t`Speed`} 42 | 43 | 44 | 45 | {t`BitTorrent`} 46 | 47 | 48 | 49 | {t`Web UI`} 50 | 51 | 52 | 53 | {t`Save`} 54 | > 55 | ); 56 | }; 57 | 58 | const usePreferencesModal = () => { 59 | const modals = useModals(); 60 | 61 | return () => 62 | modals.openModal({ 63 | title: t`Preferences`, 64 | children: , 65 | size: 'xl', 66 | ...commonModalConfiguration, 67 | }); 68 | }; 69 | 70 | export default usePreferencesModal; 71 | -------------------------------------------------------------------------------- /src/meridian/torrentProperties/modals/tabs/transferTab.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Box, ScrollArea, createStyles } from '@mantine/core'; 6 | 7 | import { LabelWithText } from 'meridian/generic'; 8 | import { TorrentProperties } from 'meridian/models'; 9 | import { bytesToSize } from 'meridian/utils'; 10 | 11 | type Props = Pick< 12 | TorrentProperties, 13 | | 'total_downloaded' 14 | | 'total_uploaded' 15 | | 'total_downloaded_session' 16 | | 'total_uploaded_session' 17 | | 'peers' 18 | | 'seeds' 19 | | 'peers_total' 20 | | 'seeds_total' 21 | | 'up_speed_avg' 22 | | 'dl_speed_avg' 23 | >; 24 | 25 | const TransferTab = (props: Props) => { 26 | const styles = useStyles(); 27 | const leftItems = { 28 | [t`Average download speed`]: bytesToSize(props.dl_speed_avg), 29 | [t`Downloaded (alltime)`]: bytesToSize(props.total_downloaded), 30 | [t`Downloaded (session)`]: bytesToSize(props.total_downloaded_session), 31 | [t`Peers`]: props.peers.toString(), 32 | [t`Peers (total)`]: props.peers_total.toString(), 33 | }; 34 | const rightItems = { 35 | [t`Average upload speed`]: bytesToSize(props.up_speed_avg), 36 | [t`Uploaded (alltime)`]: bytesToSize(props.total_uploaded), 37 | [t`Uploaded (session)`]: bytesToSize(props.total_uploaded_session), 38 | [t`Seeds`]: props.seeds.toString(), 39 | [t`Seeds (total)`]: props.seeds_total.toString(), 40 | }; 41 | 42 | return ( 43 | 44 | 45 | 46 | {Object.entries(leftItems).map(([label, text], index) => ( 47 | 48 | ))} 49 | 50 | 51 | 52 | {Object.entries(rightItems).map(([label, text], index) => ( 53 | 54 | ))} 55 | 56 | 57 | 58 | ); 59 | }; 60 | 61 | const useStyles = createStyles({ 62 | scroll: { 63 | height: '50vh', 64 | }, 65 | root: { 66 | display: 'flex', 67 | flexDirection: 'row', 68 | }, 69 | space: { 70 | flex: 1, 71 | }, 72 | }); 73 | 74 | export default memo(TransferTab); 75 | -------------------------------------------------------------------------------- /src/meridian/torrent/card/statusInfo.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | import { Download, Upload } from 'tabler-icons-react'; 5 | 6 | import { Box, Group, MantineStyleSystemProps, createStyles } from '@mantine/core'; 7 | 8 | import { LabelWithBadge, LabelWithText } from 'meridian/generic'; 9 | import { 10 | TorrentState, 11 | TorrentStateDescriptionCollorMapping, 12 | getTorrentStateDescription, 13 | } from 'meridian/models'; 14 | import { bytesToSize } from 'meridian/utils'; 15 | 16 | interface Props extends MantineStyleSystemProps { 17 | state: TorrentState; 18 | category: string; 19 | tags: string; 20 | dlSpeed: number; 21 | upSpeed: number; 22 | } 23 | 24 | const DownloadIcon = () => { 25 | const { theme } = useStyles(); 26 | return ; 27 | }; 28 | 29 | const UploadIcon = () => { 30 | const { theme } = useStyles(); 31 | return ; 32 | }; 33 | 34 | const StatusInfo = ({ state, category, tags, dlSpeed, upSpeed, ...props }: Props) => { 35 | const styles = useStyles(); 36 | const torrentStateDescription = getTorrentStateDescription(state); 37 | return ( 38 | 39 | 40 | 45 | {category !== '' ? : null} 46 | {tags !== '' ? : null} 47 | 48 | 49 | } 54 | /> 55 | } 60 | /> 61 | 62 | 63 | ); 64 | }; 65 | 66 | const useStyles = createStyles({ 67 | root: { 68 | flex: 1, 69 | display: 'flex', 70 | alignItems: 'center', 71 | justifyContent: 'space-between', 72 | }, 73 | }); 74 | 75 | export default memo(StatusInfo); 76 | -------------------------------------------------------------------------------- /src/meridian/generic/colorSchemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { Moon, MoonStars, Sun } from 'tabler-icons-react'; 5 | 6 | import { ActionIcon, Box, Center, SegmentedControl, createStyles } from '@mantine/core'; 7 | 8 | import { createSetSettingsAction, selectSettings } from 'meridian/settings'; 9 | 10 | const ColorSchemeToggle = () => { 11 | const settings = useSelector(selectSettings); 12 | const dispatch = useDispatch(); 13 | 14 | return ( 15 | 17 | dispatch( 18 | createSetSettingsAction({ 19 | ...settings, 20 | darkMode: !settings.darkMode, 21 | }), 22 | ) 23 | } 24 | sx={(theme) => ({ 25 | color: theme.colorScheme === 'dark' ? theme.colors.yellow[4] : theme.colors.blue[6], 26 | })} 27 | > 28 | {!settings.darkMode ? : } 29 | 30 | ); 31 | }; 32 | 33 | const SegmentedColorSchemeToggle = () => { 34 | const styles = useStyles(); 35 | const settings = useSelector(selectSettings); 36 | const dispatch = useDispatch(); 37 | const scheme = settings.darkMode ? 'dark' : 'light'; 38 | return ( 39 | 43 | dispatch( 44 | createSetSettingsAction({ 45 | ...settings, 46 | darkMode: !settings.darkMode, 47 | }), 48 | ) 49 | } 50 | data={[ 51 | { 52 | value: 'light', 53 | label: ( 54 | 55 | 56 | Light 57 | 58 | ), 59 | }, 60 | { 61 | value: 'dark', 62 | label: ( 63 | 64 | 65 | Dark 66 | 67 | ), 68 | }, 69 | ]} 70 | /> 71 | ); 72 | }; 73 | 74 | const useStyles = createStyles({ 75 | segmentedControl: { 76 | width: '100%', 77 | }, 78 | }); 79 | 80 | export { ColorSchemeToggle, SegmentedColorSchemeToggle }; 81 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/webui/authenticationSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Accordion, NumberInput, PasswordInput, Switch, TextInput, Textarea } from '@mantine/core'; 6 | 7 | import { SectionProps } from '../types'; 8 | 9 | const AuthenticationSection = ({ preferences, updatePreferencesKey }: SectionProps) => ( 10 | 11 | updatePreferencesKey('web_ui_username', value.target.value)} 16 | /> 17 | updatePreferencesKey('web_ui_password', value.target.value)} 23 | /> 24 | 29 | updatePreferencesKey('bypass_auth_subnet_whitelist_enabled', value.target.checked) 30 | } 31 | /> 32 | 38 | updatePreferencesKey('bypass_auth_subnet_whitelist', value.target.value) 39 | } 40 | /> 41 | 46 | updatePreferencesKey('web_ui_max_auth_fail_count', value as number) 47 | } 48 | /> 49 | updatePreferencesKey('web_ui_ban_duration', value as number)} 54 | /> 55 | updatePreferencesKey('web_ui_session_timeout', value as number)} 60 | /> 61 | 62 | ); 63 | 64 | export default AuthenticationSection; 65 | -------------------------------------------------------------------------------- /src/meridian/categories/modals/useCategoriesModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { t } from '@lingui/macro'; 5 | import { Edit, Trash } from 'tabler-icons-react'; 6 | 7 | import { ActionIcon, Box, Button, Group, LoadingOverlay, Text, createStyles } from '@mantine/core'; 8 | import { useModals } from '@mantine/modals'; 9 | 10 | import { commonModalConfiguration } from 'meridian/generic'; 11 | import { useDeleteResource } from 'meridian/hooks'; 12 | import { Resource } from 'meridian/resource'; 13 | 14 | import { selectCategories } from '../state'; 15 | 16 | import useCreateCategoryModal from './useCreateCategoryModal'; 17 | import useEditCategoryModal from './useEditCategoryModal'; 18 | 19 | const CategoriesModal = () => { 20 | const styles = useStyles(); 21 | const categories = useSelector(selectCategories); 22 | const openCreateCategoryModal = useCreateCategoryModal(); 23 | const openEditCategoryModal = useEditCategoryModal(); 24 | const deleteCategories = useDeleteResource(Resource.CATEGORIES); 25 | 26 | if (!categories) { 27 | return ; 28 | } 29 | 30 | const handlers = { 31 | edit: (categoryName: string) => () => openEditCategoryModal(categories[categoryName]), 32 | delete: (categoryName: string) => () => deleteCategories([categories[categoryName]]), 33 | }; 34 | 35 | return ( 36 | <> 37 | {Object.keys(categories).map((categoryName, key) => ( 38 | 39 | {categories[categoryName].name} 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ))} 49 | 50 | {t`New`} 51 | 52 | > 53 | ); 54 | }; 55 | 56 | const useCategoriesModal = () => { 57 | const modals = useModals(); 58 | 59 | return () => 60 | modals.openModal({ 61 | title: t`Categories`, 62 | children: , 63 | ...commonModalConfiguration, 64 | }); 65 | }; 66 | 67 | const useStyles = createStyles({ 68 | space: { 69 | flexGrow: 1, 70 | backgroundColor: 'transparent', // does not flex without this? 71 | }, 72 | }); 73 | 74 | export default useCategoriesModal; 75 | -------------------------------------------------------------------------------- /src/meridian/resource/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line no-restricted-imports */ 2 | import { AddTorrentsParams } from 'meridian/api/types'; 3 | import { 4 | Category, 5 | MainData, 6 | Preferences, 7 | TorrentContent, 8 | TorrentInfo, 9 | TorrentProperties, 10 | TorrentTracker, 11 | TransferInfo, 12 | } from 'meridian/models'; 13 | 14 | export enum Resource { 15 | MAIN_DATA = 'main_data', 16 | TORRENT = 'torrent', 17 | TORRENT_PROPERTIES = 'torrent_properties', 18 | TORRENT_CONTENT = 'torrent_content', 19 | TORRENT_TRACKERS = 'torrent_tracker', 20 | TRANSFER_INFO = 'transfer_info', 21 | PREFERENCES = 'preferences', 22 | CATEGORIES = 'categories', 23 | TAGS = 'tags', 24 | } 25 | 26 | export type ResourceDataType = { 27 | [Resource.MAIN_DATA]: MainData; 28 | [Resource.TORRENT]: TorrentInfo[]; 29 | [Resource.TORRENT_PROPERTIES]: TorrentProperties; 30 | [Resource.TORRENT_CONTENT]: TorrentContent[]; 31 | [Resource.TORRENT_TRACKERS]: TorrentTracker[]; 32 | [Resource.TRANSFER_INFO]: TransferInfo; 33 | [Resource.PREFERENCES]: Preferences; 34 | [Resource.CATEGORIES]: Record; 35 | [Resource.TAGS]: string[]; 36 | }; 37 | 38 | export type FetchResourceParams = { 39 | [Resource.MAIN_DATA]: { 40 | rid?: number; 41 | }; 42 | [Resource.TORRENT]: undefined; 43 | [Resource.TORRENT_PROPERTIES]: { 44 | hash: string; 45 | }; 46 | [Resource.TORRENT_CONTENT]: { 47 | hash: string; 48 | }; 49 | [Resource.TORRENT_TRACKERS]: { 50 | hash: string; 51 | }; 52 | [Resource.TRANSFER_INFO]: undefined; 53 | [Resource.PREFERENCES]: undefined; 54 | [Resource.CATEGORIES]: undefined; 55 | [Resource.TAGS]: undefined; 56 | }; 57 | 58 | export type SetResourceParams = { 59 | [Resource.MAIN_DATA]: undefined; 60 | [Resource.TORRENT]: AddTorrentsParams; 61 | [Resource.TORRENT_PROPERTIES]: undefined; 62 | [Resource.TORRENT_CONTENT]: undefined; 63 | [Resource.TORRENT_TRACKERS]: undefined; 64 | [Resource.TRANSFER_INFO]: undefined; 65 | [Resource.PREFERENCES]: Preferences; 66 | [Resource.CATEGORIES]: { 67 | category: Category; 68 | editExisting?: boolean; 69 | }; 70 | [Resource.TAGS]: string[]; 71 | }; 72 | 73 | export type DeleteResourceParams = { 74 | [Resource.MAIN_DATA]: undefined; 75 | [Resource.TORRENT]: undefined; 76 | [Resource.TORRENT_PROPERTIES]: undefined; 77 | [Resource.TORRENT_CONTENT]: undefined; 78 | [Resource.TORRENT_TRACKERS]: undefined; 79 | [Resource.TRANSFER_INFO]: undefined; 80 | [Resource.PREFERENCES]: undefined; 81 | [Resource.CATEGORIES]: Category[]; 82 | [Resource.TAGS]: string[]; 83 | }; 84 | -------------------------------------------------------------------------------- /src/meridian/mainData/modals/useServerStateModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { t } from '@lingui/macro'; 5 | 6 | import { LoadingOverlay } from '@mantine/core'; 7 | import { useModals } from '@mantine/modals'; 8 | 9 | import { LabelWithText, commonModalConfiguration } from 'meridian/generic'; 10 | import { bytesToSize } from 'meridian/utils'; 11 | 12 | import { selectMainData } from '../state'; 13 | 14 | const ServerStateModal = () => { 15 | const mainData = useSelector(selectMainData); 16 | 17 | if (!mainData) { 18 | return ; 19 | } 20 | 21 | const transferInfo = mainData.server_state; 22 | 23 | return ( 24 | <> 25 | 29 | 34 | 39 | 44 | 49 | 54 | 55 | 60 | 65 | > 66 | ); 67 | }; 68 | 69 | const useServerStateModal = () => { 70 | const modals = useModals(); 71 | 72 | return () => 73 | modals.openModal({ 74 | title: t`Server state`, 75 | children: , 76 | ...commonModalConfiguration, 77 | }); 78 | }; 79 | 80 | export default useServerStateModal; 81 | -------------------------------------------------------------------------------- /src/meridian/generic/dropzone.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | import { X } from 'tabler-icons-react'; 5 | 6 | import { ActionIcon, Badge, Group, Text } from '@mantine/core'; 7 | import { Dropzone as LibDropzone } from '@mantine/dropzone'; 8 | 9 | import { truncateLongText } from 'meridian/utils'; 10 | 11 | import useWindowSize from './useWindowSize'; 12 | 13 | interface FileBadgeProps { 14 | file: File; 15 | onRemove: (file: File) => void; 16 | } 17 | 18 | const FileBadge = ({ file, onRemove }: FileBadgeProps) => { 19 | const { width } = useWindowSize(); 20 | const removeButton = ( 21 | onRemove(file)} 23 | size='xs' 24 | color='blue' 25 | radius='xl' 26 | variant='transparent' 27 | > 28 | 29 | 30 | ); 31 | 32 | return ( 33 | 34 | {width < 400 ? truncateLongText(file.name) : file.name} 35 | 36 | ); 37 | }; 38 | 39 | export const DropzoneChildren = ({ status }: { status: 'accept' | 'reject' | 'idle' }) => { 40 | let text = t`Drag torrent files here or click to select files`; 41 | if (status === 'accept') { 42 | text = t`Files selected. Drag here or click to select again.`; 43 | } else if (status === 'reject') { 44 | text = t`Failed to select files. Drag here or click to select again.`; 45 | } 46 | return ( 47 | 48 | 49 | 50 | {text} 51 | 52 | 53 | 54 | ); 55 | }; 56 | 57 | interface Props { 58 | files: File[]; 59 | onDrop: (files: File[]) => void; 60 | onRemove: (file: File) => void; 61 | accept?: string[]; 62 | } 63 | 64 | const Dropzone = ({ files, onDrop, onRemove, accept }: Props) => ( 65 | <> 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | {files.map((file, key) => ( 78 | 79 | ))} 80 | > 81 | ); 82 | 83 | export default Dropzone; 84 | -------------------------------------------------------------------------------- /src/meridian/state/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import { combineReducers } from 'redux'; 3 | import createSagaMiddleware from 'redux-saga'; 4 | 5 | import { categoriesReducer, categoriesSaga } from 'meridian/categories'; 6 | import { getIsDevEnv } from 'meridian/importMetaUtils'; 7 | import { mainDataReducer, mainDataSaga } from 'meridian/mainData'; 8 | import { preferencesReducer, preferencesSaga } from 'meridian/preferences'; 9 | import { sessionReducer, sessionSaga } from 'meridian/session'; 10 | import { settingsReducer } from 'meridian/settings'; 11 | import { snackbarSaga } from 'meridian/snackbar'; 12 | import { tagsReducer, tagsSaga } from 'meridian/tags'; 13 | import { torrentReducer, torrentSaga } from 'meridian/torrent'; 14 | import { torrentContentReducer, torrentContentSaga } from 'meridian/torrentContent'; 15 | import { torrentFiltersReducer } from 'meridian/torrentFilters'; 16 | import { torrentPropertiesReducer, torrentPropertiesSaga } from 'meridian/torrentProperties'; 17 | import { torrentTrackersReducer, torrentTrackersSaga } from 'meridian/torrentTrackers'; 18 | import { transferInfoReducer, transferInfoSaga } from 'meridian/transferInfo'; 19 | 20 | import { startupSaga } from './sagas'; 21 | import { GlobalState } from './types'; 22 | 23 | export const createStore = (preloadedState?: GlobalState) => { 24 | const appSagas = [ 25 | snackbarSaga, 26 | sessionSaga, 27 | mainDataSaga, 28 | torrentPropertiesSaga, 29 | torrentContentSaga, 30 | torrentTrackersSaga, 31 | torrentSaga, 32 | transferInfoSaga, 33 | preferencesSaga, 34 | categoriesSaga, 35 | tagsSaga, 36 | startupSaga, 37 | ]; 38 | 39 | const sagaMiddleware = createSagaMiddleware(); 40 | 41 | const store = configureStore({ 42 | reducer: combineReducers({ 43 | mainDataState: mainDataReducer, 44 | torrentPropertiesState: torrentPropertiesReducer, 45 | torrentContentState: torrentContentReducer, 46 | torrentTrackersState: torrentTrackersReducer, 47 | torrentState: torrentReducer, 48 | torrentFiltersState: torrentFiltersReducer, 49 | sessionState: sessionReducer, 50 | settingsState: settingsReducer, 51 | transferInfoState: transferInfoReducer, 52 | preferencesState: preferencesReducer, 53 | categoriesState: categoriesReducer, 54 | tagsState: tagsReducer, 55 | }), 56 | preloadedState, 57 | middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(sagaMiddleware), 58 | devTools: getIsDevEnv(), 59 | }); 60 | 61 | appSagas.forEach((saga) => sagaMiddleware.run(saga)); 62 | return store; 63 | }; 64 | -------------------------------------------------------------------------------- /src/meridian/preferences/modals/sections/downloads/downloadsSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { Accordion, Switch, Textarea } from '@mantine/core'; 6 | 7 | import { SectionProps } from '../types'; 8 | 9 | import AddingTorrentSection from './addingTorrentSection'; 10 | import EmailSection from './emailSection'; 11 | import SavingManagementSection from './savingManagementSection'; 12 | 13 | const DownloadsSection = (props: SectionProps) => { 14 | const { preferences, updatePreferencesKey } = props; 15 | return ( 16 | 17 | updatePreferencesKey('preallocate_all', value.target.checked)} 21 | /> 22 | 27 | updatePreferencesKey('incomplete_files_ext', value.target.checked) 28 | } 29 | /> 30 | updatePreferencesKey('autorun_enabled', value.target.checked)} 35 | /> 36 | updatePreferencesKey('autorun_program', value.target.value)} 41 | /> 42 | 43 | 44 | {t`When adding a torrent`} 45 | 46 | 47 | 48 | {t`Saving Management`} 49 | 50 | 51 | 52 | {t`Email notification upon download completion`} 53 | 54 | 55 | 56 | 57 | ); 58 | }; 59 | 60 | export default DownloadsSection; 61 | -------------------------------------------------------------------------------- /src/meridian/torrentProperties/modals/tabs/contentsTab.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { t } from '@lingui/macro'; 5 | 6 | import { 7 | Badge, 8 | Box, 9 | LoadingOverlay, 10 | RingProgress, 11 | ScrollArea, 12 | Text, 13 | Tooltip, 14 | createStyles, 15 | } from '@mantine/core'; 16 | 17 | import { FilePriorityDescription, TorrentContent } from 'meridian/models'; 18 | import { selectTorrentContent } from 'meridian/torrentContent'; 19 | import { bytesToSize } from 'meridian/utils'; 20 | 21 | const ContentsTab = () => { 22 | const styles = useStyles(); 23 | const contents = useSelector(selectTorrentContent); 24 | if (!contents) { 25 | return ; 26 | } 27 | 28 | return ( 29 | 30 | {contents.map((file) => ( 31 | 32 | ))} 33 | 34 | ); 35 | }; 36 | 37 | interface FileItemProps { 38 | file: TorrentContent; 39 | } 40 | 41 | const FileItem = ({ file }: FileItemProps) => { 42 | const styles = useStyles(); 43 | 44 | return ( 45 | 46 | 52 | 53 | 54 | {file.name} 55 | 56 | 57 | {bytesToSize(file.size)} 58 | 59 | {FilePriorityDescription[file.priority]} 60 | 61 | 62 | 63 | 64 | ); 65 | }; 66 | 67 | const useStyles = createStyles((theme) => ({ 68 | scroll: { 69 | height: '50vh', 70 | }, 71 | item: { 72 | marginBlock: 10, 73 | padding: 5, 74 | display: 'flex', 75 | flexDirection: 'row', 76 | alignItems: 'center', 77 | backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0], 78 | borderRadius: theme.radius.md, 79 | }, 80 | container: { 81 | flex: 1, 82 | display: 'flex', 83 | flexDirection: 'column', 84 | marginLeft: 10, 85 | }, 86 | container2: { 87 | flex: 1, 88 | display: 'flex', 89 | justifyContent: 'space-between', 90 | }, 91 | })); 92 | 93 | export default ContentsTab; 94 | -------------------------------------------------------------------------------- /src/meridian/torrent/useContextMenuItems.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | import { 5 | BoxMultiple, 6 | Download, 7 | FileCheck, 8 | List, 9 | PlayerPause, 10 | PlayerPlay, 11 | Tag, 12 | Trash, 13 | } from 'tabler-icons-react'; 14 | 15 | import { ContextMenuItem } from 'meridian/generic'; 16 | import { useTorrentPropertiesModal } from 'meridian/torrentProperties'; 17 | 18 | import { useTorrentActions } from './hooks'; 19 | import { useDeleteTorrentsModal, useTorrentCategoryModal, useTorrentTagsModal } from './modals'; 20 | 21 | const useContextMenuItems = (hash: string, name: string): ContextMenuItem[] => { 22 | const { pauseTorrents, resumeTorrents, forceDownloadTorrents, recheckTorrents } = 23 | useTorrentActions(); 24 | const deleteTorrents = useDeleteTorrentsModal(); 25 | const openCategoryModal = useTorrentCategoryModal(); 26 | const openTagsModal = useTorrentTagsModal(); 27 | const openPropertiesModal = useTorrentPropertiesModal(); 28 | 29 | return useMemo( 30 | () => [ 31 | { 32 | text: t`Pause`, 33 | icon: , 34 | callback: () => pauseTorrents([hash]), 35 | }, 36 | { 37 | text: t`Resume`, 38 | icon: , 39 | callback: () => resumeTorrents([hash]), 40 | }, 41 | { 42 | text: t`Force download`, 43 | icon: , 44 | callback: () => forceDownloadTorrents([hash]), 45 | }, 46 | { 47 | text: t`Recheck`, 48 | icon: , 49 | callback: () => recheckTorrents([hash]), 50 | }, 51 | { 52 | text: t`Delete`, 53 | icon: , 54 | callback: () => deleteTorrents([hash]), 55 | }, 56 | { 57 | text: t`Categories`, 58 | icon: , 59 | callback: () => openCategoryModal(hash, name), 60 | }, 61 | { 62 | text: t`Tags`, 63 | icon: , 64 | callback: () => openTagsModal(hash, name), 65 | }, 66 | { 67 | text: t`Details`, 68 | icon: , 69 | callback: () => openPropertiesModal(hash, name), 70 | }, 71 | ], 72 | [ 73 | hash, 74 | name, 75 | pauseTorrents, 76 | resumeTorrents, 77 | forceDownloadTorrents, 78 | recheckTorrents, 79 | deleteTorrents, 80 | openCategoryModal, 81 | openTagsModal, 82 | openPropertiesModal, 83 | ], 84 | ); 85 | }; 86 | 87 | export default useContextMenuItems; 88 | -------------------------------------------------------------------------------- /src/meridian/torrentProperties/modals/useTorrentPropertiesModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { t } from '@lingui/macro'; 5 | 6 | import { LoadingOverlay, Tabs } from '@mantine/core'; 7 | import { useModals } from '@mantine/modals'; 8 | 9 | import { commonModalConfiguration } from 'meridian/generic'; 10 | import { useFetchResource } from 'meridian/hooks'; 11 | import { Resource } from 'meridian/resource'; 12 | 13 | import { selectTorrentProperties } from '../state'; 14 | 15 | import { ContentsTab, GeneralTab, TrackersTab, TransferTab } from './tabs'; 16 | 17 | const TorrentPropertiesModal = () => { 18 | const torrentProperties = useSelector(selectTorrentProperties); 19 | 20 | if (!torrentProperties) { 21 | return ; 22 | } 23 | 24 | return ( 25 | 35 | 36 | {t`General`} 37 | {t`Transfer`} 38 | {t`Contents`} 39 | {t`Trackers`} 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | }; 56 | 57 | const useTorrentPropertiesModal = () => { 58 | const modals = useModals(); 59 | const fetchTorrentProperties = useFetchResource(Resource.TORRENT_PROPERTIES); 60 | const fetchTorrentContents = useFetchResource(Resource.TORRENT_CONTENT); 61 | const fetchTorrentTrackers = useFetchResource(Resource.TORRENT_TRACKERS); 62 | 63 | return useCallback( 64 | (hash: string, name: string) => { 65 | fetchTorrentProperties({ hash }); 66 | fetchTorrentContents({ hash }); 67 | fetchTorrentTrackers({ hash }); 68 | modals.openModal({ 69 | title: name, 70 | children: , 71 | size: 'xl', 72 | ...commonModalConfiguration, 73 | }); 74 | }, 75 | [modals, fetchTorrentProperties, fetchTorrentContents, fetchTorrentTrackers], 76 | ); 77 | }; 78 | 79 | export default useTorrentPropertiesModal; 80 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:react/recommended", 5 | "plugin:jsx-a11y/recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:import/recommended", 8 | "plugin:import/typescript", 9 | "plugin:react/jsx-runtime", 10 | "plugin:prettier/recommended", 11 | "prettier" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaFeatures": { 16 | "jsx": true 17 | }, 18 | "ecmaVersion": "latest", 19 | "sourceType": "module" 20 | }, 21 | "plugins": [ 22 | "react", 23 | "@typescript-eslint", 24 | "import", 25 | "jsx-a11y", 26 | "react-hooks", 27 | "prettier" 28 | ], 29 | "rules": { 30 | "@typescript-eslint/no-explicit-any": "off", 31 | "@typescript-eslint/no-non-null-assertion": "off", 32 | "@typescript-eslint/ban-ts-comment": "off", 33 | "@typescript-eslint/no-unused-vars": [ 34 | "warn", 35 | { 36 | "argsIgnorePattern": "^_", 37 | "varsIgnorePattern": "^_|React", 38 | "caughtErrorsIgnorePattern": "^_" 39 | } 40 | ], 41 | "@typescript-eslint/naming-convention": [ 42 | "error", 43 | { 44 | "selector": "variableLike", 45 | "format": [ 46 | "PascalCase", 47 | "camelCase" 48 | ], 49 | "leadingUnderscore": "allow" 50 | } 51 | ], 52 | "react-hooks/rules-of-hooks": "error", 53 | "react-hooks/exhaustive-deps": "warn", 54 | "react/jsx-uses-react": 2, 55 | "react/react-in-jsx-scope": 2, 56 | "prettier/prettier": [ 57 | "warn", 58 | { 59 | "endOfLine": "auto", 60 | "singleQuote": true 61 | } 62 | ], 63 | "prefer-const": [ 64 | "error", 65 | { 66 | "destructuring": "any", 67 | "ignoreReadBeforeAssign": false 68 | } 69 | ], 70 | "arrow-body-style": [ 71 | "error", 72 | "as-needed" 73 | ], 74 | "react/function-component-definition": [ 75 | 2, 76 | { 77 | "namedComponents": "arrow-function", 78 | "unnamedComponents": "arrow-function" 79 | } 80 | ], 81 | "no-console": "error", 82 | "no-restricted-imports": [ 83 | "error", 84 | { 85 | "patterns": [ 86 | "*/*/*" 87 | ] 88 | } 89 | ], 90 | "import/no-cycle": [ 91 | "error" 92 | ], 93 | "import/no-self-import": [ 94 | "error" 95 | ], 96 | "import/no-unresolved": [ 97 | "error" 98 | ], 99 | "import/no-commonjs": [ 100 | "error" 101 | ], 102 | "complexity": [ 103 | "error" 104 | ] 105 | }, 106 | "settings": { 107 | "react": { 108 | "version": "detect" 109 | }, 110 | "import/resolver": { 111 | "typescript": true, 112 | "node": true 113 | } 114 | } 115 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "new_app", 3 | "private": true, 4 | "version": "1.0.4", 5 | "type": "module", 6 | "scripts": { 7 | "start": "vite", 8 | "build": " rm -rf ./dist && tsc && vite build", 9 | "preview": "vite preview", 10 | "lint": "yarn eslint src", 11 | "types": "yarn tsc --noEmit -p ./tsconfig.json", 12 | "formatting": "yarn prettier -c src", 13 | "fix": "yarn lint --fix && yarn prettier -w src && yarn types", 14 | "extract": "lingui extract", 15 | "compile": "lingui compile --typescript", 16 | "deploy": "VITE_VERSION=$npm_package_version ./deploy.sh", 17 | "test": "jest" 18 | }, 19 | "dependencies": { 20 | "@mantine/core": "^5.8.0", 21 | "@mantine/dates": "^5.8.0", 22 | "@mantine/dropzone": "^5.8.0", 23 | "@mantine/form": "^5.8.0", 24 | "@mantine/hooks": "^5.8.0", 25 | "@mantine/modals": "^5.8.0", 26 | "@mantine/notifications": "^5.8.0", 27 | "@reduxjs/toolkit": "^1.8.3", 28 | "dayjs": "^1.11.3", 29 | "history": "^5.3.0", 30 | "react": "^18.2.0", 31 | "react-dom": "^18.2.0", 32 | "react-redux": "^8.0.2", 33 | "react-router-dom": "^6.3.0", 34 | "redux": "^4.2.0", 35 | "redux-saga": "^1.1.3", 36 | "tabler-icons-react": "^1.52.0" 37 | }, 38 | "devDependencies": { 39 | "@babel/core": "^7.18.6", 40 | "@babel/plugin-syntax-jsx": "^7.18.6", 41 | "@babel/preset-env": "^7.18.10", 42 | "@babel/preset-react": "^7.18.6", 43 | "@babel/preset-typescript": "^7.18.6", 44 | "@emotion/jest": "^11.10.0", 45 | "@emotion/react": "^11.10.0", 46 | "@lingui/babel-preset-react": "^2.9.2", 47 | "@lingui/cli": "^3.14.0", 48 | "@lingui/core": "^3.14.0", 49 | "@lingui/macro": "^3.14.0", 50 | "@lingui/react": "^3.14.0", 51 | "@testing-library/dom": "^8.16.0", 52 | "@testing-library/react": "^13.3.0", 53 | "@testing-library/user-event": "^14.4.1", 54 | "@trivago/prettier-plugin-sort-imports": "^3.2.0", 55 | "@types/jest": "^28.1.6", 56 | "@types/node": "^18.6.3", 57 | "@types/react": "^18.0.15", 58 | "@types/react-dom": "^18.0.6", 59 | "@typescript-eslint/eslint-plugin": "^5.30.6", 60 | "@typescript-eslint/parser": "^5.30.6", 61 | "@vitejs/plugin-react": "^2.0.0", 62 | "babel-plugin-macros": "^3.1.0", 63 | "eslint": "^8.19.0", 64 | "eslint-config-prettier": "^8.5.0", 65 | "eslint-import-resolver-typescript": "^3.4.0", 66 | "eslint-plugin-import": "^2.26.0", 67 | "eslint-plugin-jsx-a11y": "^6.6.0", 68 | "eslint-plugin-prettier": "^4.2.1", 69 | "eslint-plugin-react": "^7.30.1", 70 | "eslint-plugin-react-hooks": "^4.6.0", 71 | "jest": "^28.1.3", 72 | "jest-environment-jsdom": "^28.1.3", 73 | "jest-fetch-mock": "^3.0.3", 74 | "jest-silent-reporter": "^0.5.0", 75 | "jest-transform-stub": "^2.0.0", 76 | "prettier": "^2.7.1", 77 | "ts-jest": "^28.0.7", 78 | "ts-node": "^10.9.1", 79 | "typescript": "^4.6.4", 80 | "vite": "^3.0.0", 81 | "vite-tsconfig-paths": "^3.5.0" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/meridian/login/loginPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { t } from '@lingui/macro'; 4 | 5 | import { 6 | Avatar, 7 | Box, 8 | Button, 9 | Paper, 10 | PasswordInput, 11 | Text, 12 | TextInput, 13 | createStyles, 14 | } from '@mantine/core'; 15 | 16 | import { Page, SegmentedColorSchemeToggle } from 'meridian/generic'; 17 | import { useLogin } from 'meridian/hooks'; 18 | import { LanguagePicker } from 'meridian/i18n'; 19 | import { Icons } from 'meridian/icons'; 20 | import { LoginData } from 'meridian/models'; 21 | 22 | import useLoginForm from './useLoginForm'; 23 | 24 | const LoginPage = () => { 25 | const styles = useStyles(); 26 | const login = useLogin(); 27 | const form = useLoginForm(); 28 | 29 | const onSubmit = (data: LoginData) => login(data.username, data.password); 30 | 31 | return ( 32 | 33 | 34 | 43 | 44 | 45 | {t`Welcome to QBitUI`} 46 | 47 | 53 | 60 | 61 | 62 | 63 | 64 | {t`Theme`} 65 | 66 | 67 | 68 | {t`Sign in`} 69 | 70 | 71 | 72 | 73 | ); 74 | }; 75 | 76 | const useStyles = createStyles({ 77 | root: { 78 | flex: 1, 79 | display: 'flex', 80 | flexDirection: 'column', 81 | justifyContent: 'center', 82 | alignItems: 'center', 83 | }, 84 | logo: { 85 | display: 'flex', 86 | flexDirection: 'column', 87 | alignItems: 'center', 88 | justifyContent: 'center', 89 | }, 90 | }); 91 | 92 | export default LoginPage; 93 | --------------------------------------------------------------------------------