mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-12-06 06:36:25 +00:00
Rework settings page
This commit is contained in:
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
type QueryIdentifier,
|
||||
type ServerInfo,
|
||||
ServerInfoP,
|
||||
queryFn,
|
||||
useAccount,
|
||||
useFetch,
|
||||
} from "@kyoo/models";
|
||||
import { Button, IconButton, Link, Skeleton, tooltip, ts } from "@kyoo/primitives";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ImageBackground } from "react-native";
|
||||
import { rem, useYoshiki } from "yoshiki/native";
|
||||
import { ErrorView } from "../../../../src/ui/errors";
|
||||
import { Preference, SettingsContainer } from "./base";
|
||||
|
||||
import Badge from "@material-symbols/svg-400/outlined/badge.svg";
|
||||
import Remove from "@material-symbols/svg-400/outlined/close.svg";
|
||||
import OpenProfile from "@material-symbols/svg-400/outlined/open_in_new.svg";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const OidcSettings = () => {
|
||||
const account = useAccount()!;
|
||||
const { css } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
const { data, error } = useFetch(OidcSettings.query());
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: unlinkAccount } = useMutation({
|
||||
mutationFn: async (provider: string) =>
|
||||
await queryFn({
|
||||
path: ["auth", "login", provider],
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["auth", "me"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsContainer title={t("settings.oidc.label")}>
|
||||
{error ? (
|
||||
<ErrorView error={error} />
|
||||
) : data ? (
|
||||
Object.entries(data.oidc).map(([id, x]) => {
|
||||
const acc = account.externalId[id];
|
||||
return (
|
||||
<Preference
|
||||
key={x.displayName}
|
||||
icon={Badge}
|
||||
label={x.displayName}
|
||||
description={
|
||||
acc
|
||||
? t("settings.oidc.connected", { username: acc.username })
|
||||
: t("settings.oidc.not-connected")
|
||||
}
|
||||
customIcon={
|
||||
x.logoUrl != null && (
|
||||
<ImageBackground
|
||||
source={{ uri: x.logoUrl }}
|
||||
{...css({ width: ts(3), height: ts(3), marginRight: ts(2) })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{acc ? (
|
||||
<>
|
||||
{acc.profileUrl && (
|
||||
<IconButton
|
||||
icon={OpenProfile}
|
||||
as={Link}
|
||||
href={acc.profileUrl}
|
||||
target="_blank"
|
||||
{...tooltip(t("settings.oidc.open-profile", { provider: x.displayName }))}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={Remove}
|
||||
onPress={() => unlinkAccount(id)}
|
||||
{...tooltip(t("settings.oidc.delete", { provider: x.displayName }))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
text={t("settings.oidc.link")}
|
||||
as={Link}
|
||||
href={x.link}
|
||||
{...css({ minWidth: rem(6) })}
|
||||
/>
|
||||
)}
|
||||
</Preference>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
[...Array(3)].map((_, i) => (
|
||||
<Preference
|
||||
key={i}
|
||||
customIcon={<Skeleton {...css({ width: ts(3), height: ts(3) })} />}
|
||||
icon={null!}
|
||||
label={<Skeleton {...css({ width: rem(6) })} />}
|
||||
description={<Skeleton {...css({ width: rem(7), height: rem(0.8) })} />}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</SettingsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
OidcSettings.query = (): QueryIdentifier<ServerInfo> => ({
|
||||
path: ["info"],
|
||||
parser: ServerInfoP,
|
||||
});
|
||||
3
front/src/app/(app)/settings.tsx
Normal file
3
front/src/app/(app)/settings.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { SettingsPage } from "~/ui/settings";
|
||||
|
||||
export default SettingsPage;
|
||||
@@ -2,7 +2,7 @@ import { Stack } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTheme } from "yoshiki/native";
|
||||
import { ErrorConsumer } from "~/providers/error-consumer";
|
||||
import { NavbarTitle } from "~/ui/navbar";
|
||||
import { NavbarProfile, NavbarTitle } from "~/ui/navbar";
|
||||
|
||||
export default function Layout() {
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -13,6 +13,7 @@ export default function Layout() {
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTitle: () => <NavbarTitle />,
|
||||
headerRight: () => <NavbarProfile />,
|
||||
contentStyle: {
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
|
||||
@@ -2,42 +2,48 @@ import { z } from "zod/v4";
|
||||
|
||||
export const User = z
|
||||
.object({
|
||||
// // keep a default for older versions of the api
|
||||
// .default({}),
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
email: z.string(),
|
||||
// permissions: z.array(z.string()),
|
||||
// hasPassword: z.boolean().default(true),
|
||||
// settings: z
|
||||
// .object({
|
||||
// downloadQuality: z
|
||||
// .union([
|
||||
// z.literal("original"),
|
||||
// z.literal("8k"),
|
||||
// z.literal("4k"),
|
||||
// z.literal("1440p"),
|
||||
// z.literal("1080p"),
|
||||
// z.literal("720p"),
|
||||
// z.literal("480p"),
|
||||
// z.literal("360p"),
|
||||
// z.literal("240p"),
|
||||
// ])
|
||||
// .default("original")
|
||||
// .catch("original"),
|
||||
// audioLanguage: z.string().default("default").catch("default"),
|
||||
// subtitleLanguage: z.string().nullable().default(null).catch(null),
|
||||
// })
|
||||
// // keep a default for older versions of the api
|
||||
// .default({}),
|
||||
// externalId: z
|
||||
// .record(
|
||||
// z.string(),
|
||||
// z.object({
|
||||
// id: z.string(),
|
||||
// username: z.string().nullable().default(""),
|
||||
// profileUrl: z.string().nullable(),
|
||||
// }),
|
||||
// )
|
||||
// .default({}),
|
||||
claims: z.object({
|
||||
permissions: z.array(z.string()),
|
||||
// hasPassword: z.boolean().default(true),
|
||||
settings: z
|
||||
.object({
|
||||
downloadQuality: z
|
||||
.union([
|
||||
z.literal("original"),
|
||||
z.literal("8k"),
|
||||
z.literal("4k"),
|
||||
z.literal("1440p"),
|
||||
z.literal("1080p"),
|
||||
z.literal("720p"),
|
||||
z.literal("480p"),
|
||||
z.literal("360p"),
|
||||
z.literal("240p"),
|
||||
])
|
||||
.catch("original"),
|
||||
audioLanguage: z.string().catch("default"),
|
||||
subtitleLanguage: z.string().nullable().catch(null),
|
||||
})
|
||||
.default({
|
||||
downloadQuality: "original",
|
||||
audioLanguage: "default",
|
||||
subtitleLanguage: null,
|
||||
}),
|
||||
// externalId: z
|
||||
// .record(
|
||||
// z.string(),
|
||||
// z.object({
|
||||
// id: z.string(),
|
||||
// username: z.string().nullable().default(""),
|
||||
// profileUrl: z.string().nullable(),
|
||||
// }),
|
||||
// )
|
||||
// .default({}),
|
||||
}),
|
||||
})
|
||||
.transform((x) => ({
|
||||
...x,
|
||||
|
||||
@@ -1,23 +1,3 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Stolen from https://github.com/necolas/react-native-web/issues/1026#issuecomment-1458279681
|
||||
|
||||
import type { AlertButton, AlertOptions } from "react-native";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export { Footer, Header, Main, Nav, UL } from "@expo/html-elements";
|
||||
// export * from "./snackbar";
|
||||
export * from "./alert";
|
||||
export * from "./avatar";
|
||||
export * from "./button";
|
||||
export * from "./chip";
|
||||
@@ -9,11 +11,9 @@ export * from "./image";
|
||||
export * from "./image-background";
|
||||
export * from "./input";
|
||||
export * from "./links";
|
||||
// export * from "./snackbar";
|
||||
// export * from "./alert";
|
||||
export * from "./menu";
|
||||
export * from "./popup";
|
||||
export * from "./progress";
|
||||
// export * from "./popup";
|
||||
export * from "./select";
|
||||
export * from "./skeleton";
|
||||
export * from "./slider";
|
||||
|
||||
@@ -1,30 +1,9 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { usePortal } from "@gorhom/portal";
|
||||
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { px, vh } from "yoshiki/native";
|
||||
import { imageBorderRadius } from "./constants";
|
||||
import { Container } from "./container";
|
||||
import { ContrastArea, SwitchVariant, type YoshikiFunc } from "./themes";
|
||||
import { ContrastArea, SwitchVariant, type YoshikiFunc } from "./theme";
|
||||
import { ts } from "./utils";
|
||||
|
||||
export const Popup = ({
|
||||
@@ -52,7 +31,7 @@ export const Popup = ({
|
||||
<Container
|
||||
{...css(
|
||||
{
|
||||
borderRadius: px(imageBorderRadius),
|
||||
borderRadius: px(6),
|
||||
paddingHorizontal: 0,
|
||||
bg: (theme) => theme.background,
|
||||
overflow: "hidden",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const NativeProviders = ({ children }: { children: ReactNode }) => {
|
||||
return children;
|
||||
return <PortalProvider>{children}</PortalProvider>;
|
||||
};
|
||||
|
||||
@@ -268,7 +268,7 @@ type MutationParams = {
|
||||
body?: object;
|
||||
};
|
||||
|
||||
export const useMutation = <T = void, QueryRet>({
|
||||
export const useMutation = <T = void, QueryRet = void>({
|
||||
compute,
|
||||
invalidate,
|
||||
optimistic,
|
||||
|
||||
@@ -1,81 +1,67 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
type Account,
|
||||
type KyooErrors,
|
||||
deleteAccount,
|
||||
logout,
|
||||
queryFn,
|
||||
useAccount,
|
||||
} from "@kyoo/models";
|
||||
import { Alert, Avatar, Button, H1, Icon, Input, P, Popup, ts, usePopup } from "@kyoo/primitives";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import Username from "@material-symbols/svg-400/outlined/badge.svg";
|
||||
import Mail from "@material-symbols/svg-400/outlined/mail.svg";
|
||||
import Password from "@material-symbols/svg-400/outlined/password.svg";
|
||||
// import AccountCircle from "@material-symbols/svg-400/rounded/account_circle-fill.svg";
|
||||
import Delete from "@material-symbols/svg-400/rounded/delete.svg";
|
||||
import Logout from "@material-symbols/svg-400/rounded/logout.svg";
|
||||
// import * as ImagePicker from "expo-image-picker";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { rem, useYoshiki } from "yoshiki/native";
|
||||
import { PasswordInput } from "../../../../src/ui/login/password-input";
|
||||
import type { KyooError, User } from "~/models";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
H1,
|
||||
Icon,
|
||||
Input,
|
||||
P,
|
||||
Popup,
|
||||
ts,
|
||||
usePopup,
|
||||
} from "~/primitives";
|
||||
import { useAccount } from "~/providers/account-context";
|
||||
import { deleteAccount, logout } from "../login/logic";
|
||||
import { PasswordInput } from "../login/password-input";
|
||||
import { Preference, SettingsContainer } from "./base";
|
||||
import { useMutation } from "~/query";
|
||||
|
||||
import Username from "@material-symbols/svg-400/outlined/badge.svg";
|
||||
import Mail from "@material-symbols/svg-400/outlined/mail.svg";
|
||||
import Password from "@material-symbols/svg-400/outlined/password.svg";
|
||||
import AccountCircle from "@material-symbols/svg-400/rounded/account_circle-fill.svg";
|
||||
import Delete from "@material-symbols/svg-400/rounded/delete.svg";
|
||||
import Logout from "@material-symbols/svg-400/rounded/logout.svg";
|
||||
|
||||
function dataURItoBlob(dataURI: string) {
|
||||
const byteString = atob(dataURI.split(",")[1]);
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
return new Blob([ab], { type: "image/jpeg" });
|
||||
}
|
||||
// function dataURItoBlob(dataURI: string) {
|
||||
// const byteString = atob(dataURI.split(",")[1]);
|
||||
// const ab = new ArrayBuffer(byteString.length);
|
||||
// const ia = new Uint8Array(ab);
|
||||
// for (let i = 0; i < byteString.length; i++) {
|
||||
// ia[i] = byteString.charCodeAt(i);
|
||||
// }
|
||||
// return new Blob([ab], { type: "image/jpeg" });
|
||||
// }
|
||||
|
||||
export const AccountSettings = () => {
|
||||
const account = useAccount()!;
|
||||
const { css, theme } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
const [setPopup, close] = usePopup();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync } = useMutation({
|
||||
mutationFn: async (update: Partial<Account>) =>
|
||||
await queryFn({
|
||||
path: ["auth", "me"],
|
||||
method: "PATCH",
|
||||
body: update,
|
||||
}),
|
||||
onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["auth", "me"] }),
|
||||
method: "PATCH",
|
||||
path: ["auth", "users", "me"],
|
||||
compute: (update: Partial<User>) => ({ body: update }),
|
||||
optimistic: (update) => ({
|
||||
...account,
|
||||
...update,
|
||||
claims: { ...account.claims, ...update.claims },
|
||||
}),
|
||||
invalidate: ["auth", "users", "me"],
|
||||
});
|
||||
|
||||
const { mutateAsync: editPassword } = useMutation({
|
||||
mutationFn: async (request: { newPassword: string; oldPassword: string }) =>
|
||||
await queryFn({
|
||||
path: ["auth", "password-reset"],
|
||||
method: "POST",
|
||||
body: request,
|
||||
}),
|
||||
method: "PATCH",
|
||||
path: ["auth", "users", "me", "password"],
|
||||
compute: (body: { oldPassword: string; newPassword: string }) => ({
|
||||
body,
|
||||
}),
|
||||
invalidate: null,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -106,7 +92,8 @@ export const AccountSettings = () => {
|
||||
],
|
||||
{
|
||||
cancelable: true,
|
||||
userInterfaceStyle: theme.mode === "auto" ? "light" : theme.mode,
|
||||
userInterfaceStyle:
|
||||
theme.mode === "auto" ? "light" : theme.mode,
|
||||
icon: "warning",
|
||||
},
|
||||
);
|
||||
@@ -137,43 +124,47 @@ export const AccountSettings = () => {
|
||||
}
|
||||
/>
|
||||
</Preference>
|
||||
{/* <Preference */}
|
||||
{/* icon={AccountCircle} */}
|
||||
{/* customIcon={<Avatar src={account.logo} />} */}
|
||||
{/* label={t("settings.account.avatar.label")} */}
|
||||
{/* description={t("settings.account.avatar.description")} */}
|
||||
{/* > */}
|
||||
{/* <Button */}
|
||||
{/* text={t("misc.edit")} */}
|
||||
{/* onPress={async () => { */}
|
||||
{/* const img = await ImagePicker.launchImageLibraryAsync({ */}
|
||||
{/* mediaTypes: ImagePicker.MediaTypeOptions.Images, */}
|
||||
{/* aspect: [1, 1], */}
|
||||
{/* quality: 1, */}
|
||||
{/* base64: true, */}
|
||||
{/* }); */}
|
||||
{/* if (img.canceled || img.assets.length !== 1) return; */}
|
||||
{/* const data = dataURItoBlob(img.assets[0].uri); */}
|
||||
{/* const formData = new FormData(); */}
|
||||
{/* formData.append("picture", data); */}
|
||||
{/* await queryFn({ */}
|
||||
{/* method: "POST", */}
|
||||
{/* path: ["auth", "me", "logo"], */}
|
||||
{/* formData, */}
|
||||
{/* }); */}
|
||||
{/* }} */}
|
||||
{/* /> */}
|
||||
{/* <Button */}
|
||||
{/* text={t("misc.delete")} */}
|
||||
{/* onPress={async () => { */}
|
||||
{/* await queryFn({ */}
|
||||
{/* method: "DELETE", */}
|
||||
{/* path: ["auth", "me", "logo"], */}
|
||||
{/* }); */}
|
||||
{/* }} */}
|
||||
{/* /> */}
|
||||
{/* </Preference> */}
|
||||
<Preference
|
||||
icon={AccountCircle}
|
||||
customIcon={<Avatar src={account.logo} />}
|
||||
label={t("settings.account.avatar.label")}
|
||||
description={t("settings.account.avatar.description")}
|
||||
icon={Mail}
|
||||
label={t("settings.account.email.label")}
|
||||
description={account.email}
|
||||
>
|
||||
<Button
|
||||
text={t("misc.edit")}
|
||||
onPress={async () => {
|
||||
const img = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
aspect: [1, 1],
|
||||
quality: 1,
|
||||
base64: true,
|
||||
});
|
||||
if (img.canceled || img.assets.length !== 1) return;
|
||||
const data = dataURItoBlob(img.assets[0].uri);
|
||||
const formData = new FormData();
|
||||
formData.append("picture", data);
|
||||
await queryFn({
|
||||
method: "POST",
|
||||
path: ["auth", "me", "logo"],
|
||||
formData,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
text={t("misc.delete")}
|
||||
onPress={async () => {
|
||||
await queryFn({
|
||||
method: "DELETE",
|
||||
path: ["auth", "me", "logo"],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Preference>
|
||||
<Preference icon={Mail} label={t("settings.account.email.label")} description={account.email}>
|
||||
<Button
|
||||
text={t("misc.edit")}
|
||||
onPress={() =>
|
||||
@@ -202,8 +193,10 @@ export const AccountSettings = () => {
|
||||
<ChangePasswordPopup
|
||||
icon={Password}
|
||||
label={t("settings.account.password.label")}
|
||||
hasPassword={account.hasPassword}
|
||||
apply={async (op, np) => await editPassword({ oldPassword: op, newPassword: np })}
|
||||
hasPassword={true}
|
||||
apply={async (op, np) =>
|
||||
await editPassword({ oldPassword: op, newPassword: np })
|
||||
}
|
||||
close={close}
|
||||
/>,
|
||||
)
|
||||
@@ -236,7 +229,9 @@ const ChangePopup = ({
|
||||
<Popup>
|
||||
{({ css }) => (
|
||||
<>
|
||||
<View {...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}>
|
||||
<View
|
||||
{...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}
|
||||
>
|
||||
<Icon icon={icon} />
|
||||
<H1 {...css({ fontSize: rem(2) })}>{label}</H1>
|
||||
</View>
|
||||
@@ -246,7 +241,13 @@ const ChangePopup = ({
|
||||
value={value}
|
||||
onChangeText={(v) => setValue(v)}
|
||||
/>
|
||||
<View {...css({ flexDirection: "row", alignSelf: "flex-end", gap: ts(1) })}>
|
||||
<View
|
||||
{...css({
|
||||
flexDirection: "row",
|
||||
alignSelf: "flex-end",
|
||||
gap: ts(1),
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
text={t("misc.cancel")}
|
||||
onPress={() => close()}
|
||||
@@ -289,7 +290,9 @@ const ChangePasswordPopup = ({
|
||||
<Popup>
|
||||
{({ css }) => (
|
||||
<>
|
||||
<View {...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}>
|
||||
<View
|
||||
{...css({ flexDirection: "row", alignItems: "center", gap: ts(2) })}
|
||||
>
|
||||
<Icon icon={icon} />
|
||||
<H1 {...css({ fontSize: rem(2) })}>{label}</H1>
|
||||
</View>
|
||||
@@ -303,14 +306,22 @@ const ChangePasswordPopup = ({
|
||||
/>
|
||||
)}
|
||||
<PasswordInput
|
||||
autoComplete="password-new"
|
||||
autoComplete="new-password"
|
||||
variant="big"
|
||||
value={newValue}
|
||||
onChangeText={(v) => setNewValue(v)}
|
||||
placeholder={t("settings.account.password.newPassword")}
|
||||
/>
|
||||
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>}
|
||||
<View {...css({ flexDirection: "row", alignSelf: "flex-end", gap: ts(1) })}>
|
||||
{error && (
|
||||
<P {...css({ color: (theme) => theme.colors.red })}>{error}</P>
|
||||
)}
|
||||
<View
|
||||
{...css({
|
||||
flexDirection: "row",
|
||||
alignSelf: "flex-end",
|
||||
gap: ts(1),
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
text={t("misc.cancel")}
|
||||
onPress={() => close()}
|
||||
@@ -323,7 +334,7 @@ const ChangePasswordPopup = ({
|
||||
await apply(oldValue, newValue);
|
||||
close();
|
||||
} catch (e) {
|
||||
setError((e as KyooErrors).errors[0]);
|
||||
setError((e as KyooError).message);
|
||||
}
|
||||
}}
|
||||
{...css({ minWidth: rem(6) })}
|
||||
@@ -115,26 +115,37 @@ export const SettingsContainer = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const useSetting = <Setting extends keyof User["settings"]>(
|
||||
export const useSetting = <Setting extends keyof User["claims"]["settings"]>(
|
||||
setting: Setting,
|
||||
) => {
|
||||
const account = useAccount();
|
||||
const { mutateAsync } = useMutation({
|
||||
method: "PATCH",
|
||||
path: ["auth", "me"],
|
||||
compute: (update: Partial<User["settings"]>) => ({
|
||||
body: { settings: { ...account!.settings, ...update } },
|
||||
path: ["auth", "users", "me"],
|
||||
compute: (update: Partial<User["claims"]["settings"]>) => ({
|
||||
body: {
|
||||
claims: {
|
||||
...account!.claims,
|
||||
settings: { ...account!.claims.settings, ...update },
|
||||
},
|
||||
},
|
||||
}),
|
||||
optimistic: (update) => ({
|
||||
body: { ...account, settings: { ...account!.settings, ...update } },
|
||||
body: {
|
||||
...account,
|
||||
claims: {
|
||||
...account!.claims,
|
||||
settings: { ...account!.claims.settings, ...update },
|
||||
},
|
||||
},
|
||||
}),
|
||||
invalidate: ["auth", "me"],
|
||||
invalidate: ["auth", "users", "me"],
|
||||
});
|
||||
|
||||
if (!account) return null;
|
||||
return [
|
||||
account.settings[setting],
|
||||
async (value: User["settings"][Setting]) => {
|
||||
account.claims.settings[setting],
|
||||
async (value: User["claims"]["settings"][Setting]) => {
|
||||
await mutateAsync({ [setting]: value });
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Theme from "@material-symbols/svg-400/outlined/dark_mode.svg";
|
||||
// import Theme from "@material-symbols/svg-400/outlined/dark_mode.svg";
|
||||
import Language from "@material-symbols/svg-400/outlined/language.svg";
|
||||
import Android from "@material-symbols/svg-400/rounded/android.svg";
|
||||
import Public from "@material-symbols/svg-400/rounded/public.svg";
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { ScrollView } from "react-native";
|
||||
import { ts } from "~/primitives";
|
||||
import { useAccount } from "~/providers/account-context";
|
||||
// import { AccountSettings } from "./account";
|
||||
import { AccountSettings } from "./account";
|
||||
import { About, GeneralSettings } from "./general";
|
||||
// import { OidcSettings } from "./oidc";
|
||||
// import { PlaybackSettings } from "./playback";
|
||||
import { PlaybackSettings } from "./playback";
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const account = useAccount();
|
||||
return (
|
||||
<ScrollView contentContainerStyle={{ gap: ts(4), paddingBottom: ts(4) }}>
|
||||
<GeneralSettings />
|
||||
{/* {account && <PlaybackSettings />} */}
|
||||
{/* {account && <AccountSettings />} */}
|
||||
{account && <PlaybackSettings />}
|
||||
{account && <AccountSettings />}
|
||||
{/* {account && <OidcSettings />} */}
|
||||
<About />
|
||||
</ScrollView>
|
||||
|
||||
108
front/src/ui/settings/oidc.tsx
Normal file
108
front/src/ui/settings/oidc.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
// import {
|
||||
// type QueryIdentifier,
|
||||
// type ServerInfo,
|
||||
// ServerInfoP,
|
||||
// queryFn,
|
||||
// useAccount,
|
||||
// useFetch,
|
||||
// } from "@kyoo/models";
|
||||
// import { Button, IconButton, Link, Skeleton, tooltip, ts } from "@kyoo/primitives";
|
||||
// import { useTranslation } from "react-i18next";
|
||||
// import { ImageBackground } from "react-native";
|
||||
// import { rem, useYoshiki } from "yoshiki/native";
|
||||
// import { ErrorView } from "../errors";
|
||||
// import { Preference, SettingsContainer } from "./base";
|
||||
//
|
||||
// import Badge from "@material-symbols/svg-400/outlined/badge.svg";
|
||||
// import Remove from "@material-symbols/svg-400/outlined/close.svg";
|
||||
// import OpenProfile from "@material-symbols/svg-400/outlined/open_in_new.svg";
|
||||
// import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
//
|
||||
// export const OidcSettings = () => {
|
||||
// const account = useAccount()!;
|
||||
// const { css } = useYoshiki();
|
||||
// const { t } = useTranslation();
|
||||
// const { data, error } = useFetch(OidcSettings.query());
|
||||
// const queryClient = useQueryClient();
|
||||
// const { mutateAsync: unlinkAccount } = useMutation({
|
||||
// mutationFn: async (provider: string) =>
|
||||
// await queryFn({
|
||||
// path: ["auth", "login", provider],
|
||||
// method: "DELETE",
|
||||
// }),
|
||||
// onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["auth", "me"] }),
|
||||
// });
|
||||
//
|
||||
// return (
|
||||
// <SettingsContainer title={t("settings.oidc.label")}>
|
||||
// {error ? (
|
||||
// <ErrorView error={error} />
|
||||
// ) : data ? (
|
||||
// Object.entries(data.oidc).map(([id, x]) => {
|
||||
// const acc = account.externalId[id];
|
||||
// return (
|
||||
// <Preference
|
||||
// key={x.displayName}
|
||||
// icon={Badge}
|
||||
// label={x.displayName}
|
||||
// description={
|
||||
// acc
|
||||
// ? t("settings.oidc.connected", { username: acc.username })
|
||||
// : t("settings.oidc.not-connected")
|
||||
// }
|
||||
// customIcon={
|
||||
// x.logoUrl != null && (
|
||||
// <ImageBackground
|
||||
// source={{ uri: x.logoUrl }}
|
||||
// {...css({ width: ts(3), height: ts(3), marginRight: ts(2) })}
|
||||
// />
|
||||
// )
|
||||
// }
|
||||
// >
|
||||
// {acc ? (
|
||||
// <>
|
||||
// {acc.profileUrl && (
|
||||
// <IconButton
|
||||
// icon={OpenProfile}
|
||||
// as={Link}
|
||||
// href={acc.profileUrl}
|
||||
// target="_blank"
|
||||
// {...tooltip(t("settings.oidc.open-profile", { provider: x.displayName }))}
|
||||
// />
|
||||
// )}
|
||||
// <IconButton
|
||||
// icon={Remove}
|
||||
// onPress={() => unlinkAccount(id)}
|
||||
// {...tooltip(t("settings.oidc.delete", { provider: x.displayName }))}
|
||||
// />
|
||||
// </>
|
||||
// ) : (
|
||||
// <Button
|
||||
// text={t("settings.oidc.link")}
|
||||
// as={Link}
|
||||
// href={x.link}
|
||||
// {...css({ minWidth: rem(6) })}
|
||||
// />
|
||||
// )}
|
||||
// </Preference>
|
||||
// );
|
||||
// })
|
||||
// ) : (
|
||||
// [...Array(3)].map((_, i) => (
|
||||
// <Preference
|
||||
// key={i}
|
||||
// customIcon={<Skeleton {...css({ width: ts(3), height: ts(3) })} />}
|
||||
// icon={null!}
|
||||
// label={<Skeleton {...css({ width: rem(6) })} />}
|
||||
// description={<Skeleton {...css({ width: rem(7), height: rem(0.8) })} />}
|
||||
// />
|
||||
// ))
|
||||
// )}
|
||||
// </SettingsContainer>
|
||||
// );
|
||||
// };
|
||||
//
|
||||
// OidcSettings.query = (): QueryIdentifier<ServerInfo> => ({
|
||||
// path: ["info"],
|
||||
// parser: ServerInfoP,
|
||||
// });
|
||||
@@ -1,32 +1,26 @@
|
||||
/*
|
||||
* Kyoo - A portable and vast media library solution.
|
||||
* Copyright (c) Kyoo.
|
||||
*
|
||||
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
*
|
||||
* Kyoo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* Kyoo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { languageCodes, useLanguageName } from "../utils";
|
||||
import { Preference, SettingsContainer, useSetting } from "./base";
|
||||
|
||||
import { useLocalSetting } from "@kyoo/models";
|
||||
import { Select } from "@kyoo/primitives";
|
||||
import SubtitleLanguage from "@material-symbols/svg-400/rounded/closed_caption-fill.svg";
|
||||
import PlayModeI from "@material-symbols/svg-400/rounded/display_settings-fill.svg";
|
||||
import AudioLanguage from "@material-symbols/svg-400/rounded/music_note-fill.svg";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Select } from "~/primitives";
|
||||
import { useLocalSetting } from "~/providers/settings";
|
||||
import { useLanguageName } from "~/track-utils";
|
||||
import { Preference, SettingsContainer, useSetting } from "./base";
|
||||
import langmap from "langmap";
|
||||
|
||||
const seenNativeNames = new Set();
|
||||
export const languageCodes = Object.keys(langmap)
|
||||
.filter((x) => {
|
||||
const nativeName = langmap[x]?.nativeName;
|
||||
|
||||
// Only include if nativeName is unique and defined
|
||||
if (nativeName && !seenNativeNames.has(nativeName)) {
|
||||
seenNativeNames.add(nativeName);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.filter((x) => !x.includes("@"));
|
||||
|
||||
export const PlaybackSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -61,7 +55,9 @@ export const PlaybackSettings = () => {
|
||||
onValueChange={(value) => setAudio(value)}
|
||||
values={["default", ...languageCodes]}
|
||||
getLabel={(key) =>
|
||||
key === "default" ? t("mediainfo.default") : (getLanguageName(key) ?? key)
|
||||
key === "default"
|
||||
? t("mediainfo.default")
|
||||
: (getLanguageName(key) ?? key)
|
||||
}
|
||||
/>
|
||||
</Preference>
|
||||
@@ -73,7 +69,9 @@ export const PlaybackSettings = () => {
|
||||
<Select
|
||||
label={t("settings.playback.subtitleLanguage.label")}
|
||||
value={subtitle ?? "none"}
|
||||
onValueChange={(value) => setSubtitle(value === "none" ? null : value)}
|
||||
onValueChange={(value) =>
|
||||
setSubtitle(value === "none" ? null : value)
|
||||
}
|
||||
values={["none", "default", ...languageCodes]}
|
||||
getLabel={(key) =>
|
||||
key === "none"
|
||||
Reference in New Issue
Block a user