diff --git a/src/App.tsx b/src/App.tsx
index 8c75487f..da8af5f4 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -6,9 +6,9 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { Container } from '@mui/material';
+import { Box, Container } from '@mui/material';
import CssBaseline from '@mui/material/CssBaseline';
-import React, { useLayoutEffect } from 'react';
+import React, { useEffect, useLayoutEffect, useMemo } from 'react';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { loadErrorMessages, loadDevMessages } from '@apollo/client/dev';
import { AppContext } from '@/components/context/AppContext';
@@ -38,6 +38,9 @@ import { requestManager } from '@/lib/requests/RequestManager.ts';
import { BrowseSettings } from '@/screens/settings/BrowseSettings.tsx';
import { WebUISettings } from '@/screens/settings/WebUISettings.tsx';
import { Migrate } from '@/screens/Migrate.tsx';
+import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
+import { getActiveDevice, setActiveDevice } from '@/util/device.ts';
+import { DeviceSetting } from '@/components/settings/DeviceSetting.tsx';
if (process.env.NODE_ENV !== 'production') {
// Adds messages only in a dev environment
@@ -69,67 +72,88 @@ const BackgroundSubscriptions = () => {
return null;
};
+const ActiveDeviceListener = ({ children }: { children?: React.ReactNode }) => {
+ const {
+ settings: { devices, activeDevice },
+ } = useMetadataServerSettings();
+
+ useEffect(() => {
+ if (activeDevice === getActiveDevice()) {
+ return;
+ }
+
+ setActiveDevice(activeDevice);
+ }, [devices, activeDevice]);
+
+ const memorizedChildren = useMemo(() => children, [activeDevice]);
+
+ return {memorizedChildren};
+};
+
export const App: React.FC = () => (
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ {/* General Routes */}
+ } />
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* Manga Routes */}
+
+
+ } />
+ } />
+ } />
+ } />
+
+ } />
+
+
+ } />
+
+ } />
+ } />
+ } />
+ } />
+
+ } />
+ } />
+
+
+
- {/* General Routes */}
- } />
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
- {/* Manga Routes */}
-
-
- } />
- } />
- } />
- } />
-
- } />
-
-
- } />
-
- } />
- } />
- } />
- } />
-
- } />
- } />
-
+ } />
+
-
-
- } />
-
-
+
);
diff --git a/src/components/navbar/ReaderNavBar.tsx b/src/components/navbar/ReaderNavBar.tsx
index c43ecc1f..e0a1043d 100644
--- a/src/components/navbar/ReaderNavBar.tsx
+++ b/src/components/navbar/ReaderNavBar.tsx
@@ -184,6 +184,7 @@ export function ReaderNavBar(props: IProps) {
// main container and root div need to change styles...
rootEl.style.display = 'flex';
+ rootEl.style.flexDirection = 'column';
mainContainer.style.display = 'none';
return () => {
diff --git a/src/components/settings/DeviceSetting.tsx b/src/components/settings/DeviceSetting.tsx
new file mode 100644
index 00000000..094fe401
--- /dev/null
+++ b/src/components/settings/DeviceSetting.tsx
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) Contributors to the Suwayomi project
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import { List, ListItem, ListItemText, MenuItem, Select } from '@mui/material';
+import { useTranslation } from 'react-i18next';
+import { useContext, useEffect } from 'react';
+import { convertSettingsToMetadata, useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
+import { MetadataServerSettingKeys, MetadataServerSettings } from '@/typings.ts';
+import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata.ts';
+import { makeToast } from '@/components/util/Toast.tsx';
+import { MutableListSetting } from '@/components/settings/MutableListSetting.tsx';
+import { NavBarContext } from '@/components/context/NavbarContext.tsx';
+import { DEFAULT_DEVICE } from '@/util/device.ts';
+
+export const DeviceSetting = () => {
+ const { t } = useTranslation();
+ const { setTitle, setAction } = useContext(NavBarContext);
+
+ useEffect(() => {
+ setTitle(t('settings.device.title.settings'));
+ setAction(null);
+
+ return () => {
+ setTitle('');
+ setAction(null);
+ };
+ }, [t]);
+
+ const {
+ metadata,
+ settings: { devices, activeDevice },
+ } = useMetadataServerSettings();
+
+ const updateMetadataSetting = async (
+ setting: Setting,
+ value: MetadataServerSettings[Setting],
+ ) => {
+ if (!metadata) {
+ return;
+ }
+
+ const wasActiveDeviceDeleted = setting === 'devices' && !(value as string[]).includes(activeDevice);
+ if (wasActiveDeviceDeleted) {
+ try {
+ await requestUpdateServerMetadata(convertToGqlMeta(metadata) ?? [], [
+ ['activeDevice', convertSettingsToMetadata({ activeDevice: DEFAULT_DEVICE }).activeDevice],
+ ]);
+ } catch (e) {
+ makeToast(t('global.error.label.failed_to_save_changes'), 'error');
+ return;
+ }
+ }
+
+ requestUpdateServerMetadata(convertToGqlMeta(metadata) ?? [], [
+ [setting, convertSettingsToMetadata({ [setting]: value })[setting]],
+ ]).catch(() => makeToast(t('global.error.label.failed_to_save_changes'), 'error'));
+ };
+
+ return (
+
+ {
+ updateMetadataSetting('devices', [...new Set([DEFAULT_DEVICE, ...deviceList])]);
+ }}
+ valueInfos={devices.map((device) => [device, { mutable: false, deletable: device !== DEFAULT_DEVICE }])}
+ addItemButtonTitle={t('global.button.create')}
+ />
+
+
+
+
+
+ );
+};
diff --git a/src/components/settings/MutableListSetting.tsx b/src/components/settings/MutableListSetting.tsx
index 8359b7c2..7c4bf1fa 100644
--- a/src/components/settings/MutableListSetting.tsx
+++ b/src/components/settings/MutableListSetting.tsx
@@ -6,7 +6,17 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { Button, Dialog, DialogTitle, ListItemButton, ListItemText, Stack, Tooltip, Typography } from '@mui/material';
+import {
+ Button,
+ Dialog,
+ DialogTitle,
+ ListItem,
+ ListItemButton,
+ ListItemText,
+ Stack,
+ Tooltip,
+ Typography,
+} from '@mui/material';
import { useEffect, useState } from 'react';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
@@ -22,15 +32,27 @@ import { makeToast } from '@/components/util/Toast.tsx';
const MutableListItem = ({
handleDelete,
+ mutable = true,
+ deletable = true,
...textSettingProps
-}: Omit & { handleDelete: () => void }) => {
+}: Omit & {
+ handleDelete: () => void;
+ mutable?: boolean;
+ deletable?: boolean;
+}) => {
const { t } = useTranslation();
return (
-
+ {mutable ? (
+
+ ) : (
+
+
+
+ )}
-
+
@@ -39,7 +61,10 @@ const MutableListItem = ({
};
type MutableListSettingProps = Pick & {
- values?: string[];
+ valueInfos?: (
+ | [value: string]
+ | [value: string, Pick, 'mutable' | 'deletable'>]
+ )[];
description?: string;
dialogDisclaimer?: JSX.Element | string;
addItemButtonTitle?: string;
@@ -49,11 +74,14 @@ type MutableListSettingProps = Pick
+ valueInfos?.map((valueInfo) => valueInfo[0]) ?? [];
+
export const MutableListSetting = ({
settingName,
description,
dialogDisclaimer,
- values,
+ valueInfos,
handleChange,
addItemButtonTitle,
placeholder,
@@ -63,22 +91,24 @@ export const MutableListSetting = ({
}: MutableListSettingProps) => {
const { t } = useTranslation();
+ const values = getValues(valueInfos);
+
const [isDialogOpen, setIsDialogOpen] = useState(false);
- const [dialogValues, setDialogValues] = useState(values ?? []);
+ const [dialogValues, setDialogValues] = useState(values);
const [isAddItemDialogOpen, setIsAddItemDialogOpen] = useState(false);
useEffect(() => {
- if (!values) {
+ if (!valueInfos) {
return;
}
setDialogValues(values);
- }, [values]);
+ }, [valueInfos]);
const closeDialog = (resetValue: boolean = true) => {
if (resetValue) {
- setDialogValues(values ?? []);
+ setDialogValues(values);
}
setIsDialogOpen(false);
@@ -165,6 +195,8 @@ export const MutableListSetting = ({
handleChange={(newValue: string) => updateSetting(index, newValue)}
handleDelete={() => updateSetting(index, undefined)}
value={dialogValue}
+ mutable={valueInfos?.find(([value]) => value === dialogValue)?.[1]?.mutable}
+ deletable={valueInfos?.find(([value]) => value === dialogValue)?.[1]?.deletable}
/>
))}
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json
index 41dd6023..992868f2 100644
--- a/src/i18n/locale/en.json
+++ b/src/i18n/locale/en.json
@@ -785,6 +785,24 @@
"title": "Clear server cache"
}
},
+ "device": {
+ "active_device": {
+ "label": {
+ "description": "Select a device to use its server stored UI settings",
+ "title": "Active device"
+ }
+ },
+ "devices": {
+ "label": {
+ "description": "Manage your existing devices.\nUI specific settings that are stored on the server are per device.\nThis makes it possible to have e.g. different settings on a desktop and a smartphone",
+ "title": "Devices"
+ }
+ },
+ "title": {
+ "device": "Device",
+ "settings": "Device settings"
+ }
+ },
"label": {
"dark_theme": "Dark theme",
"language_description": "Feel free to translate the project on",
diff --git a/src/screens/Settings.tsx b/src/screens/Settings.tsx
index 6ec6eece..135eb062 100644
--- a/src/screens/Settings.tsx
+++ b/src/screens/Settings.tsx
@@ -27,6 +27,7 @@ import DnsIcon from '@mui/icons-material/Dns';
import WebIcon from '@mui/icons-material/Web';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import ExploreOutlinedIcon from '@mui/icons-material/ExploreOutlined';
+import DevicesIcon from '@mui/icons-material/Devices';
import { langCodeToName } from '@/util/language';
import { useLocalStorage } from '@/util/useLocalStorage';
import { ListItemLink } from '@/components/util/ListItemLink';
@@ -161,6 +162,12 @@ export function Settings() {
+
+
+
+
+
+
diff --git a/src/screens/settings/BrowseSettings.tsx b/src/screens/settings/BrowseSettings.tsx
index 707cb7f7..879b1068 100644
--- a/src/screens/settings/BrowseSettings.tsx
+++ b/src/screens/settings/BrowseSettings.tsx
@@ -92,7 +92,7 @@ export const BrowseSettings = () => {
updateSetting('extensionRepos', repos);
requestManager.clearExtensionCache();
}}
- values={serverSettings?.extensionRepos}
+ valueInfos={serverSettings?.extensionRepos.map((extensionRepo) => [extensionRepo]) as [string][]}
addItemButtonTitle={t('extension.settings.repositories.custom.dialog.action.button.add')}
placeholder="https://github.com/MY_ACCOUNT/MY_REPO/tree/repo"
validateItem={(repo) =>
diff --git a/src/screens/settings/DownloadSettings.tsx b/src/screens/settings/DownloadSettings.tsx
index 2e253d3c..bfe3ede0 100644
--- a/src/screens/settings/DownloadSettings.tsx
+++ b/src/screens/settings/DownloadSettings.tsx
@@ -16,7 +16,7 @@ import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarC
import { MetadataServerSettingKeys, MetadataServerSettings, ServerSettings } from '@/typings.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { DownloadAheadSetting } from '@/components/settings/downloads/DownloadAheadSetting.tsx';
-import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
+import { convertSettingsToMetadata, useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata.ts';
import { makeToast } from '@/components/util/Toast.tsx';
import { DeleteChaptersWhileReadingSetting } from '@/components/settings/downloads/DeleteChaptersWhileReadingSetting.tsx';
@@ -78,9 +78,9 @@ export const DownloadSettings = () => {
return;
}
- requestUpdateServerMetadata(convertToGqlMeta(metadata) ?? [], [[setting, value]]).catch(() =>
- makeToast(t('global.error.label.failed_to_save_changes'), 'error'),
- );
+ requestUpdateServerMetadata(convertToGqlMeta(metadata) ?? [], [
+ [setting, convertSettingsToMetadata({ [setting]: value })[setting]],
+ ]).catch(() => makeToast(t('global.error.label.failed_to_save_changes'), 'error'));
};
return (
diff --git a/src/screens/settings/LibrarySettings.tsx b/src/screens/settings/LibrarySettings.tsx
index 10e12c13..3d6e5325 100644
--- a/src/screens/settings/LibrarySettings.tsx
+++ b/src/screens/settings/LibrarySettings.tsx
@@ -16,7 +16,7 @@ import { GlobalUpdateSettings } from '@/components/settings/globalUpdate/GlobalU
import { MetadataServerSettingKeys, MetadataServerSettings } from '@/typings.ts';
import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata.ts';
import { makeToast } from '@/components/util/Toast.tsx';
-import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
+import { convertSettingsToMetadata, useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { Mangas } from '@/lib/data/Mangas.ts';
@@ -61,9 +61,9 @@ export function LibrarySettings() {
setting: Setting,
value: MetadataServerSettings[Setting],
) => {
- requestUpdateServerMetadata(convertToGqlMeta(metadata)! ?? {}, [[setting, value]]).catch(() =>
- makeToast(t('search.error.label.failed_to_save_settings'), 'warning'),
- );
+ requestUpdateServerMetadata(convertToGqlMeta(metadata)! ?? {}, [
+ [setting, convertSettingsToMetadata({ [setting]: value })[setting]],
+ ]).catch(() => makeToast(t('search.error.label.failed_to_save_settings'), 'warning'));
};
return (
diff --git a/src/typings.ts b/src/typings.ts
index a02ae941..208b6305 100644
--- a/src/typings.ts
+++ b/src/typings.ts
@@ -229,6 +229,10 @@ export type MetadataServerSettings = {
showAddToLibraryCategorySelectDialog: boolean;
ignoreFilters: boolean;
removeMangaFromCategories: boolean;
+
+ // client
+ devices: string[];
+ activeDevice: string;
};
export interface ISearchSettings {
diff --git a/src/util/HelperFunctions.ts b/src/util/HelperFunctions.ts
new file mode 100644
index 00000000..24f20dd6
--- /dev/null
+++ b/src/util/HelperFunctions.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright (C) Contributors to the Suwayomi project
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+export const jsonSaveParse = (...args: Parameters): T | null => {
+ try {
+ return JSON.parse(...args);
+ } catch (e) {
+ return null;
+ }
+};
diff --git a/src/util/device.ts b/src/util/device.ts
new file mode 100644
index 00000000..1fd8bbd1
--- /dev/null
+++ b/src/util/device.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright (C) Contributors to the Suwayomi project
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+export const DEFAULT_DEVICE = 'default';
+
+let activeDevice = DEFAULT_DEVICE;
+export const getActiveDevice = (): string => activeDevice;
+export const setActiveDevice = (device: string) => {
+ activeDevice = device;
+};
diff --git a/src/util/metadata.ts b/src/util/metadata.ts
index 786b324c..3abff760 100644
--- a/src/util/metadata.ts
+++ b/src/util/metadata.ts
@@ -20,9 +20,12 @@ import {
} from '@/typings';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { MetaType } from '@/lib/graphql/generated/graphql.ts';
+import { DEFAULT_DEVICE, getActiveDevice } from '@/util/device.ts';
const APP_METADATA_KEY_PREFIX = 'webUI_';
+const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = ['devices', 'activeDevice'];
+
/**
* Once all changes have been done in the current branch, a new migration for all changes should be
* created.
@@ -97,7 +100,12 @@ const getAppKeyPrefixForMigration = (migrationId: number): string => {
return appKeyPrefix?.appKeyPrefix?.newPrefix ?? APP_METADATA_KEY_PREFIX;
};
-const getMetadataKey = (key: string, appPrefix: string = APP_METADATA_KEY_PREFIX) => `${appPrefix}${key}`;
+const getMetadataKey = (key: string, appPrefix: string = APP_METADATA_KEY_PREFIX) => {
+ const isGlobalMetadataKey = GLOBAL_METADATA_KEYS.includes(key as AppMetadataKeys);
+ const addActiveDevicePrefix = !isGlobalMetadataKey && getActiveDevice() !== DEFAULT_DEVICE;
+
+ return `${appPrefix}${addActiveDevicePrefix ? `${getActiveDevice()}_` : ''}${key}`;
+};
const doesMetadataKeyExistIn = (meta: Metadata | undefined, key: string, appPrefix?: string): boolean =>
Object.prototype.hasOwnProperty.call(meta ?? {}, getMetadataKey(key, appPrefix));
diff --git a/src/util/metadataServerSettings.ts b/src/util/metadataServerSettings.ts
index 28f8f497..84479888 100644
--- a/src/util/metadataServerSettings.ts
+++ b/src/util/metadataServerSettings.ts
@@ -6,9 +6,11 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { Metadata, MetadataServerSettings } from '@/typings';
+import { AllowedMetadataValueTypes, AppMetadataKeys, Metadata, MetadataServerSettings } from '@/typings';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { convertFromGqlMeta, getMetadataFrom } from '@/util/metadata';
+import { jsonSaveParse } from '@/util/HelperFunctions.ts';
+import { DEFAULT_DEVICE } from '@/util/device.ts';
export const getDefaultSettings = (): MetadataServerSettings => ({
// downloads
@@ -21,13 +23,36 @@ export const getDefaultSettings = (): MetadataServerSettings => ({
showAddToLibraryCategorySelectDialog: true,
ignoreFilters: false,
removeMangaFromCategories: false,
+
+ // client
+ devices: [DEFAULT_DEVICE],
+ activeDevice: DEFAULT_DEVICE,
});
+export const convertSettingsToMetadata = (
+ settings: Partial,
+): Metadata => ({
+ ...settings,
+ devices: JSON.stringify(settings.devices),
+});
+
+export const convertMetadataToSettings = (
+ metadata: Partial>,
+): MetadataServerSettings =>
+ ({
+ ...getDefaultSettings(),
+ ...(metadata as unknown as MetadataServerSettings),
+ devices: jsonSaveParse((metadata.devices as string) ?? '') ?? getDefaultSettings().devices,
+ }) satisfies MetadataServerSettings;
+
const getMetadataServerSettingsWithDefaultFallback = (
meta?: Metadata,
defaultSettings: MetadataServerSettings = getDefaultSettings(),
applyMetadataMigration: boolean = true,
-): MetadataServerSettings => getMetadataFrom({ meta }, defaultSettings, applyMetadataMigration);
+): MetadataServerSettings =>
+ convertMetadataToSettings(
+ getMetadataFrom({ meta }, convertSettingsToMetadata(defaultSettings), applyMetadataMigration),
+ );
export const useMetadataServerSettings = (): {
metadata?: Metadata;
settings: MetadataServerSettings;