diff --git a/front/apps/mobile/app/_layout.tsx b/front/apps/mobile/app/_layout.tsx index 27f3bad5..ca8cda53 100644 --- a/front/apps/mobile/app/_layout.tsx +++ b/front/apps/mobile/app/_layout.tsx @@ -23,7 +23,7 @@ import "react-native-reanimated"; import { PortalProvider } from "@gorhom/portal"; import { ThemeSelector } from "@kyoo/primitives"; import { DownloadProvider } from "@kyoo/ui"; -import { AccountProvider, createQueryClient, storage } from "@kyoo/models"; +import { AccountProvider, createQueryClient, storage, useUserTheme } from "@kyoo/models"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import i18next from "i18next"; @@ -111,9 +111,12 @@ SplashScreen.preventAutoHideAsync(); export default function Root() { const [queryClient] = useState(() => createQueryClient()); - const theme = useColorScheme(); + let theme = useUserTheme(); + const systemTheme = useColorScheme(); const [fontsLoaded] = useFonts({ Poppins_300Light, Poppins_400Regular, Poppins_900Black }); + if (theme === "auto") theme = systemTheme ?? "light"; + if (!fontsLoaded) return null; return ( { const App = ({ Component, pageProps }: AppProps) => { const [queryClient] = useState(() => createQueryClient()); - const { queryState, token, randomItems, account, ...props } = superjson.deserialize( + const { queryState, token, randomItems, account, theme, ...props } = superjson.deserialize( pageProps ?? { json: {} }, ); const layoutInfo = (Component as QueryPage).getLayout ?? (({ page }) => page); const { Layout, props: layoutProps } = typeof layoutInfo === "function" ? { Layout: layoutInfo, props: {} } : layoutInfo; + const userTheme = useUserTheme(theme); useMobileHover(); // Set the auth from the server (if the token was refreshed during SSR). @@ -136,7 +135,7 @@ const App = ({ Component, pageProps }: AppProps) => { - + { ...(getLayoutUrl ? getLayoutUrl(ctx.router.query as any, items) : []), ]; - const account = readAccountCookie(ctx.ctx.req?.headers.cookie); - const [authToken, token] = await getTokenWJ(account ?? null); + const account = readCookie(ctx.ctx.req?.headers.cookie, "account", AccountP); + const [authToken, token] = await getTokenWJ(account); appProps.pageProps.queryState = await fetchQuery(urls, authToken); appProps.pageProps.token = token; appProps.pageProps.account = account; + appProps.pageProps.theme = readCookie(ctx.ctx.req?.headers.cookie, "theme") ?? "auto"; return { pageProps: superjson.serialize(appProps.pageProps) }; }; diff --git a/front/packages/models/src/account-internal.ts b/front/packages/models/src/account-internal.ts index c925c6d3..fbfbd1cc 100644 --- a/front/packages/models/src/account-internal.ts +++ b/front/packages/models/src/account-internal.ts @@ -18,7 +18,7 @@ * along with Kyoo. If not, see . */ -import { z } from "zod"; +import { ZodObject, ZodRawShape, z } from "zod"; import { Account, AccountP } from "./accounts"; import { MMKV } from "react-native-mmkv"; @@ -34,21 +34,25 @@ const writeAccounts = (accounts: Account[]) => { storage.set("accounts", JSON.stringify(accounts)); }; -export const setAccountCookie = (account?: Account) => { - let value = JSON.stringify(account); +export const setCookie = (key: string, val?: unknown) => { + let value = JSON.stringify(val); // Remove illegal values from json. There should not be one in the account anyways. value = value?.replaceAll(";", ""); const d = new Date(); // A year d.setTime(d.getTime() + 365 * 24 * 60 * 60 * 1000); const expires = value ? "expires=" + d.toUTCString() : "expires=Thu, 01 Jan 1970 00:00:01 GMT"; - document.cookie = "account=" + value + ";" + expires + ";path=/;samesite=strict"; + document.cookie = key + "=" + value + ";" + expires + ";path=/;samesite=strict"; return null; }; -export const readAccountCookie = (cookies?: string) => { +export const readCookie = ( + cookies: string | undefined, + key: string, + parser?: ZodObject, +) => { if (!cookies) return null; - const name = "account="; + const name = `${key}=`; const decodedCookie = decodeURIComponent(cookies); const ca = decodedCookie.split(";"); for (let i = 0; i < ca.length; i++) { @@ -58,7 +62,8 @@ export const readAccountCookie = (cookies?: string) => { } if (c.indexOf(name) == 0) { const str = c.substring(name.length, c.length); - return AccountP.parse(JSON.parse(str)); + const ret = JSON.parse(str); + return parser ? parser.parse(ret) : ret; } } return null; diff --git a/front/packages/models/src/accounts.tsx b/front/packages/models/src/accounts.tsx index ff48332c..7d57b465 100644 --- a/front/packages/models/src/accounts.tsx +++ b/front/packages/models/src/accounts.tsx @@ -22,7 +22,7 @@ import { ReactNode, createContext, useContext, useEffect, useMemo, useRef } from import { User, UserP } from "./resources"; import { z } from "zod"; import { zdate } from "./utils"; -import { removeAccounts, setAccountCookie, updateAccount } from "./account-internal"; +import { removeAccounts, setCookie, updateAccount } from "./account-internal"; import { useMMKVString } from "react-native-mmkv"; import { Platform } from "react-native"; import { useFetch } from "./query"; @@ -122,7 +122,7 @@ export const AccountProvider = ({ oldSelectedId.current = selected?.id; // update cookies for ssr (needs to contains token, theme, language...) - if (Platform.OS === "web") setAccountCookie(selected); + if (Platform.OS === "web") setCookie("account", selected); }, [selected, queryClient]); return ( diff --git a/front/packages/models/src/index.ts b/front/packages/models/src/index.ts index 4c8a5eca..16f6aaba 100644 --- a/front/packages/models/src/index.ts +++ b/front/packages/models/src/index.ts @@ -20,6 +20,7 @@ export * from "./accounts"; export { storage } from "./account-internal"; +export * from "./theme"; export * from "./resources"; export * from "./traits"; export * from "./page"; diff --git a/front/packages/models/src/theme.ts b/front/packages/models/src/theme.ts new file mode 100644 index 00000000..01523423 --- /dev/null +++ b/front/packages/models/src/theme.ts @@ -0,0 +1,36 @@ +/* + * 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 . + */ + +import { useMMKVString } from "react-native-mmkv"; +import { setCookie, storage } from "./account-internal"; +import { Platform } from "react-native"; + +export const useUserTheme = (ssrTheme?: "light" | "dark" | "auto") => { + if (Platform.OS === "web" && typeof window === "undefined" && ssrTheme) return ssrTheme; + // eslint-disable-next-line react-hooks/rules-of-hooks + const [value] = useMMKVString("theme", storage); + if (!value) return "auto"; + return value as "light" | "dark" | "auto"; +}; + +export const setUserTheme = (theme: "light" | "dark" | "auto") => { + storage.set("theme", theme); + if (Platform.OS === "web") setCookie("theme", theme); +}; diff --git a/front/packages/primitives/src/select.tsx b/front/packages/primitives/src/select.tsx index 58a28625..6c12f979 100644 --- a/front/packages/primitives/src/select.tsx +++ b/front/packages/primitives/src/select.tsx @@ -23,7 +23,7 @@ import ExpandMore from "@material-symbols/svg-400/rounded/expand_more-fill.svg"; import { Menu } from "./menu"; import { Button } from "./button"; -export const Select = ({ +export const Select = ({ label, value, onValueChange, @@ -31,10 +31,10 @@ export const Select = ({ getLabel, }: { label: string; - value: string; - onValueChange: (v: string) => void; - values: string[]; - getLabel: (key: string) => string; + value: Value; + onValueChange: (v: Value) => void; + values: Value[]; + getLabel: (key: Value) => string; }) => { return ( }> @@ -43,7 +43,7 @@ export const Select = ({ key={x} label={getLabel(x)} selected={x === value} - onSelect={() => onValueChange(value)} + onSelect={() => onValueChange(x)} /> ))} diff --git a/front/packages/ui/src/settings/index.tsx b/front/packages/ui/src/settings/index.tsx index 2e68b0f2..da367c1f 100644 --- a/front/packages/ui/src/settings/index.tsx +++ b/front/packages/ui/src/settings/index.tsx @@ -18,7 +18,7 @@ * along with Kyoo. If not, see . */ -import { QueryIdentifier, QueryPage, User, UserP } from "@kyoo/models"; +import { QueryIdentifier, QueryPage, User, UserP, setUserTheme, useUserTheme } from "@kyoo/models"; import { Container, P, Select, ts } from "@kyoo/primitives"; import { DefaultLayout } from "../layout"; import { ReactNode } from "react"; @@ -56,15 +56,16 @@ const query: QueryIdentifier = { export const SettingsPage: QueryPage = () => { const { t } = useTranslation(); + const theme = useUserTheme("auto"); return (