Add back login/register (& server url picker on mobile)

This commit is contained in:
Zoe Roux 2025-06-23 00:34:43 +02:00
parent 552926d2cb
commit dfe0d52b1e
No known key found for this signature in database
33 changed files with 671 additions and 561 deletions

View File

@ -18,21 +18,27 @@ export default function TabsLayout() {
name="index" name="index"
options={{ options={{
tabBarLabel: t("navbar.home"), tabBarLabel: t("navbar.home"),
tabBarIcon: ({ color, size }) => <Icon icon={Home} color={color} size={size} />, tabBarIcon: ({ color, size }) => (
<Icon icon={Home} color={color} size={size} />
),
}} }}
/> />
<Tabs.Screen <Tabs.Screen
name="browse" name="browse"
options={{ options={{
tabBarLabel: t("navbar.browse"), tabBarLabel: t("navbar.browse"),
tabBarIcon: ({ color, size }) => <Icon icon={Browse} color={color} size={size} />, tabBarIcon: ({ color, size }) => (
<Icon icon={Browse} color={color} size={size} />
),
}} }}
/> />
<Tabs.Screen <Tabs.Screen
name="downloads" name="downloads"
options={{ options={{
tabBarLabel: t("navbar.download"), tabBarLabel: t("navbar.download"),
tabBarIcon: ({ color, size }) => <Icon icon={Downloading} color={color} size={size} />, tabBarIcon: ({ color, size }) => (
<Icon icon={Downloading} color={color} size={size} />
),
}} }}
/> />
</Tabs> </Tabs>

View File

@ -1,9 +1,9 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import { type LibraryItem, LibraryItemP } from "~/models"; import { Show } from "~/models";
import { P } from "~/primitives"; import { P } from "~/primitives";
import { Fetch, type QueryIdentifier, prefetch } from "~/query"; import { Fetch, prefetch, type QueryIdentifier } from "~/query";
export async function loader() { export async function loader() {
await prefetch(Header.query()); await prefetch(Header.query());
@ -18,17 +18,17 @@ export default function Header() {
<P>{t("home.recommended")}</P> <P>{t("home.recommended")}</P>
<Fetch <Fetch
query={Header.query()} query={Header.query()}
Render={({ name }) => <P {...css({ bg: "red" })}>{name}</P>} Render={({ name }) => <P {...(css({ bg: "red" }) as any)}>{name}</P>}
Loader={() => <P>Loading</P>} Loader={() => <P>Loading</P>}
/> />
</View> </View>
); );
} }
Header.query = (): QueryIdentifier<LibraryItem> => ({ Header.query = (): QueryIdentifier<Show> => ({
parser: LibraryItemP, parser: Show,
path: ["items", "random"], path: ["shows", "random"],
params: { params: {
fields: ["firstEpisode"], fields: ["firstEntry"],
}, },
}); });

View File

@ -12,21 +12,15 @@ export default function Layout() {
<ErrorConsumer scope="app"> <ErrorConsumer scope="app">
<Stack <Stack
screenOptions={{ screenOptions={{
// navigationBarColor: "transparent",
// navigationBarTranslucent: true,
// statusBarTranslucent: true,
// statusBarBackgroundColor: theme.accent,
headerTitle: () => <NavbarTitle />, headerTitle: () => <NavbarTitle />,
headerRight: () => <NavbarRight />, headerRight: () => <NavbarRight />,
contentStyle: { contentStyle: {
backgroundColor: theme.background,
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
}, },
headerStyle: { headerStyle: {
backgroundColor: theme.accent, backgroundColor: theme.accent,
}, },
headerTintColor: theme.colors.white,
}} }}
/> />
</ErrorConsumer> </ErrorConsumer>

View File

@ -0,0 +1,27 @@
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";
export default function Layout() {
const insets = useSafeAreaInsets();
const theme = useTheme();
return (
<ErrorConsumer scope="login">
<Stack
screenOptions={{
headerTitle: () => <NavbarTitle />,
contentStyle: {
paddingLeft: insets.left,
paddingRight: insets.right,
},
headerStyle: {
backgroundColor: theme.accent,
},
}}
/>
</ErrorConsumer>
);
}

View File

@ -0,0 +1,3 @@
import { LoginPage } from "~/ui/login";
export default LoginPage;

View File

@ -0,0 +1,3 @@
import { RegisterPage } from "~/ui/login";
export default RegisterPage;

View File

@ -1,22 +0,0 @@
// import { createMiddleware, setServerData } from "one";
// import { supportedLanguages } from "~/providers/translations.compile";
//
// export default createMiddleware(({ request, next }) => {
// const systemLanguage = request.headers
// .get("accept-languages")
// ?.split(",")
// .map((x) => {
// const [lang, q] = x.trim().split(";q=");
// return [lang, q ? Number.parseFloat(q) : 1] as const;
// })
// .sort(([_, q1], [__, q2]) => q1 - q2)
// .flatMap(([lang]) => {
// const [base, spec] = lang.split("-");
// if (spec) return [lang, base];
// return [lang];
// })
// .find((x) => supportedLanguages.includes(x));
// setServerData("systemLanguage", systemLanguage);
// setServerData("cookies", request.headers.get("Cookies") ?? "");
// return next();
// });

View File

@ -38,7 +38,6 @@ import { useFetch } from "./query";
import { ServerInfoP, type User, UserP } from "./resources"; import { ServerInfoP, type User, UserP } from "./resources";
import { zdate } from "./utils"; import { zdate } from "./utils";
const defaultApiUrl = Platform.OS === "web" ? "/api" : null;
const currentApiUrl = atom<string | null>(defaultApiUrl); const currentApiUrl = atom<string | null>(defaultApiUrl);
export const getCurrentApiUrl = () => { export const getCurrentApiUrl = () => {
const store = getDefaultStore(); const store = getDefaultStore();

View File

@ -0,0 +1,11 @@
import { z } from "zod";
import { User } from "./user";
export const AccountP = User.and(
z.object({
token: TokenP,
apiUrl: z.string(),
selected: z.boolean(),
}),
);
export type Account = z.infer<typeof AccountP>;

View File

@ -9,4 +9,6 @@ export * from "./entry";
export * from "./studio"; export * from "./studio";
export * from "./video"; export * from "./video";
export * from "./user";
export * from "./utils/images"; export * from "./utils/images";

View File

@ -1,23 +0,0 @@
import { z } from "zod";
import { zdate } from "../utils";
import { UserP } from "./user";
export const TokenP = z.object({
token_type: z.literal("Bearer"),
access_token: z.string(),
refresh_token: z.string(),
expire_in: z.string(),
expire_at: zdate(),
});
export type Token = z.infer<typeof TokenP>;
export const AccountP = UserP.and(
z.object({
// set it optional for accounts logged in before the kind was present
kind: z.literal("user").optional(),
token: TokenP,
apiUrl: z.string(),
selected: z.boolean(),
}),
);
export type Account = z.infer<typeof AccountP>;

View File

@ -1,67 +0,0 @@
import { z } from "zod";
import { ResourceP } from "../traits";
export const UserP = ResourceP("user")
.extend({
/**
* The name of this user.
*/
username: z.string(),
/**
* The user email address.
*/
email: z.string(),
/**
* The list of permissions of the user. The format of this is implementation dependent.
*/
permissions: z.array(z.string()),
/**
* Does the user can sign-in with a password or only via oidc?
*/
hasPassword: z.boolean().default(true),
/**
* User settings
*/
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({}),
/**
* User accounts on other services.
*/
externalId: z
.record(
z.string(),
z.object({
id: z.string(),
username: z.string().nullable().default(""),
profileUrl: z.string().nullable(),
}),
)
.default({}),
})
.transform((x) => ({
...x,
logo: `/user/${x.slug}/logo`,
isVerified: x.permissions.length > 0,
isAdmin: x.permissions?.includes("admin.write"),
}));
export type User = z.infer<typeof UserP>;

58
front/src/models/user.ts Normal file
View File

@ -0,0 +1,58 @@
import { z } from "zod/v4";
export const User = z
.object({
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({}),
})
.transform((x) => ({
...x,
logo: `auth/users/${x.id}/logo`,
// isVerified: x.permissions.length > 0,
// isAdmin: x.permissions?.includes("admin.write"),
}));
export type User = z.infer<typeof User>;
// not an api stuff, used internally
export const Account = User.and(
z.object({
apiUrl: z.string(),
token: z.string(),
selected: z.boolean(),
}),
);
export type Account = z.infer<typeof Account>;

View File

@ -8,9 +8,9 @@ export const KImage = z
}) })
.transform((x) => ({ .transform((x) => ({
...x, ...x,
low: `/images/${x.id}?quality=low`, low: `/api/images/${x.id}?quality=low`,
medium: `/images/${x.id}?quality=medium`, medium: `/api/images/${x.id}?quality=medium`,
high: `/images/${x.id}?quality=high`, high: `/api/images/${x.id}?quality=high`,
})); }));
export type KImage = z.infer<typeof KImage>; export type KImage = z.infer<typeof KImage>;

View File

@ -3,21 +3,26 @@ import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { import {
type ComponentProps, type ComponentProps,
type ComponentType, type ComponentType,
forwardRef,
type ReactElement, type ReactElement,
type ReactNode, type ReactNode,
forwardRef,
} from "react"; } from "react";
import type { PressableProps } from "react-native"; import type { PressableProps } from "react-native";
import type { SvgProps } from "react-native-svg"; import type { SvgProps } from "react-native-svg";
import { useYoshiki as useNativeYoshiki } from "yoshiki/native"; import { useYoshiki as useNativeYoshiki } from "yoshiki/native";
import { useYoshiki } from "yoshiki/web"; import { useYoshiki } from "yoshiki/web";
import { ContrastArea, SwitchVariant } from "~/primitives";
import { Icon } from "./icons"; import { Icon } from "./icons";
import { Link } from "./links";
import { P } from "./text"; import { P } from "./text";
import { ContrastArea, SwitchVariant } from "./theme";
import { focusReset, ts } from "./utils"; import { focusReset, ts } from "./utils";
type YoshikiFunc<T> = (props: ReturnType<typeof useYoshiki>) => T; type YoshikiFunc<T> = (props: ReturnType<typeof useYoshiki>) => T;
export const YoshikiProvider = ({ children }: { children: YoshikiFunc<ReactNode> }) => { export const YoshikiProvider = ({
children,
}: {
children: YoshikiFunc<ReactNode>;
}) => {
const yoshiki = useYoshiki(); const yoshiki = useYoshiki();
return <>{children(yoshiki)}</>; return <>{children(yoshiki)}</>;
}; };
@ -26,7 +31,12 @@ export const InternalTriger = forwardRef<unknown, any>(function _Triger(
ref, ref,
) { ) {
return ( return (
<Component ref={ref} {...ComponentProps} {...props} onClickCapture={props.onPointerDown} /> <Component
ref={ref}
{...ComponentProps}
{...props}
onClickCapture={props.onPointerDown}
/>
); );
}); });
@ -74,7 +84,8 @@ const Menu = <AsProps extends { onPress: PressableProps["onPress"] }>({
boxShadow: boxShadow:
"0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)", "0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)",
zIndex: 2, zIndex: 2,
maxHeight: "calc(var(--radix-dropdown-menu-content-available-height) * 0.8)", maxHeight:
"calc(var(--radix-dropdown-menu-content-available-height) * 0.8)",
})} })}
> >
{children} {children}
@ -89,25 +100,25 @@ const Menu = <AsProps extends { onPress: PressableProps["onPress"] }>({
); );
}; };
const Item = forwardRef< const Item = ({
HTMLDivElement, children,
ComponentProps<typeof DropdownMenu.Item> & { href?: string } href,
>(function _Item({ children, href, onSelect, ...props }, ref) { onSelect,
...props
}: ComponentProps<typeof DropdownMenu.Item> & { href?: string }) => {
if (href) { if (href) {
return ( return (
<DropdownMenu.Item ref={ref} onSelect={onSelect} {...props} asChild> <DropdownMenu.Item onSelect={onSelect} {...props} asChild>
<Link href={href} style={{ textDecoration: "none" }}> <Link href={href}>{children}</Link>
{children}
</Link>
</DropdownMenu.Item> </DropdownMenu.Item>
); );
} }
return ( return (
<DropdownMenu.Item ref={ref} onSelect={onSelect} {...props}> <DropdownMenu.Item onSelect={onSelect} {...props}>
{children} {children}
</DropdownMenu.Item> </DropdownMenu.Item>
); );
}); };
const MenuItem = forwardRef< const MenuItem = forwardRef<
HTMLDivElement, HTMLDivElement,
@ -117,8 +128,14 @@ const MenuItem = forwardRef<
left?: ReactElement; left?: ReactElement;
disabled?: boolean; disabled?: boolean;
selected?: boolean; selected?: boolean;
} & ({ onSelect: () => void; href?: undefined } | { href: string; onSelect?: undefined }) } & (
>(function MenuItem({ label, icon, left, selected, onSelect, href, disabled, ...props }, ref) { | { onSelect: () => void; href?: undefined }
| { href: string; onSelect?: undefined }
)
>(function MenuItem(
{ label, icon, left, selected, onSelect, href, disabled, ...props },
ref,
) {
const { css: nCss } = useNativeYoshiki(); const { css: nCss } = useNativeYoshiki();
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
@ -133,17 +150,17 @@ const MenuItem = forwardRef<
return ( return (
<> <>
<style jsx global>{` {/* <style jsx global>{` */}
[data-highlighted] { {/* [data-highlighted] { */}
background: ${theme.variant.accent}; {/* background: ${theme.variant.accent}; */}
svg { {/* svg { */}
fill: ${theme.alternate.contrast}; {/* fill: ${theme.alternate.contrast}; */}
} {/* } */}
div { {/* div { */}
color: ${theme.alternate.contrast}; {/* color: ${theme.alternate.contrast}; */}
} {/* } */}
} {/* } */}
`}</style> {/* `}</style> */}
<Item <Item
ref={ref} ref={ref}
onSelect={onSelect} onSelect={onSelect}
@ -164,7 +181,10 @@ const MenuItem = forwardRef<
{!left && icn && icn} {!left && icn && icn}
<P <P
{...nCss([ {...nCss([
{ paddingLeft: 8 * 2 + +!(icon || selected || left) * 24, flexGrow: 1 }, {
paddingLeft: 8 * 2 + +!(icon || selected || left) * 24,
flexGrow: 1,
},
disabled && { disabled && {
color: theme.overlay0, color: theme.overlay0,
}, },
@ -197,7 +217,11 @@ const Sub = <AsProps,>({
return ( return (
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger asChild disabled={disabled}> <DropdownMenu.SubTrigger asChild disabled={disabled}>
<MenuItem disabled={disabled} {...props} onSelect={(e?: any) => e.preventDefault()} /> <MenuItem
disabled={disabled}
{...props}
onSelect={(e?: any) => e.preventDefault()}
/>
</DropdownMenu.SubTrigger> </DropdownMenu.SubTrigger>
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.SubContent <DropdownMenu.SubContent
@ -210,7 +234,8 @@ const Sub = <AsProps,>({
boxShadow: boxShadow:
"0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)", "0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)",
zIndex: 2, zIndex: 2,
maxHeight: "calc(var(--radix-dropdown-menu-content-available-height) * 0.8)", maxHeight:
"calc(var(--radix-dropdown-menu-content-available-height) * 0.8)",
})} })}
> >
{children} {children}

View File

@ -1,13 +1,12 @@
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import { type Account, ServerInfoP, type Token } from "~/models"; import type { Account } from "~/models";
import { useFetch } from "~/query";
export const AccountContext = createContext<{ export const AccountContext = createContext<{
apiUrl: string; apiUrl: string;
authToken: string | null; //Token | null; authToken: string | null;
selectedAccount: Account | null; selectedAccount: Account | null;
accounts: (Account & { select: () => void; remove: () => void })[]; accounts: (Account & { select: () => void; remove: () => void })[];
}>({ apiUrl: "api", authToken: null, selectedAccount: null, accounts: [] }); }>({ apiUrl: "", authToken: null, selectedAccount: null, accounts: [] });
export const useToken = () => { export const useToken = () => {
const { apiUrl, authToken } = useContext(AccountContext); const { apiUrl, authToken } = useContext(AccountContext);
@ -23,17 +22,3 @@ export const useAccounts = () => {
const { accounts } = useContext(AccountContext); const { accounts } = useContext(AccountContext);
return accounts; return accounts;
}; };
export const useHasPermission = (perms?: string[]) => {
const account = useAccount();
const { data } = useFetch({
path: ["info"],
parser: ServerInfoP,
});
if (!perms || !perms[0]) return true;
const available = account?.permissions ?? data?.guestPermissions;
if (!available) return false;
return perms.every((perm) => available.includes(perm));
};

View File

@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useEffect, useMemo, useRef } from "react"; import { type ReactNode, useEffect, useMemo, useRef } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { AccountP, UserP } from "~/models"; import { Account, User } from "~/models";
import { useFetch } from "~/query"; import { useFetch } from "~/query";
import { AccountContext } from "./account-context"; import { AccountContext } from "./account-context";
import { removeAccounts, updateAccount } from "./account-store"; import { removeAccounts, updateAccount } from "./account-store";
@ -11,12 +11,12 @@ import { useStoreValue } from "./settings";
export const AccountProvider = ({ children }: { children: ReactNode }) => { export const AccountProvider = ({ children }: { children: ReactNode }) => {
const [setError, clearError] = useSetError("account"); const [setError, clearError] = useSetError("account");
const accounts = useStoreValue("accounts", z.array(AccountP)) ?? []; const accounts = useStoreValue("accounts", z.array(Account)) ?? [];
const ret = useMemo(() => { const ret = useMemo(() => {
const acc = accounts.find((x) => x.selected); const acc = accounts.find((x) => x.selected);
return { return {
apiUrl: Platform.OS === "web" ? "/api" : acc?.apiUrl!, apiUrl: acc?.apiUrl ?? "",
authToken: acc?.token ?? null, authToken: acc?.token ?? null,
selectedAccount: acc ?? null, selectedAccount: acc ?? null,
accounts: accounts.map((account) => ({ accounts: accounts.map((account) => ({
@ -34,13 +34,13 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => {
data: user, data: user,
error: userError, error: userError,
} = useFetch({ } = useFetch({
path: ["auth", "me"], path: ["auth", "users", "me"],
parser: UserP, parser: User,
placeholderData: ret.selectedAccount, placeholderData: ret.selectedAccount,
enabled: !!ret.selectedAccount, enabled: !!ret.selectedAccount,
options: { options: {
apiUrl: ret.apiUrl, apiUrl: ret.apiUrl,
authToken: ret.authToken?.access_token, authToken: ret.authToken,
}, },
}); });
// Use a ref here because we don't want the effect to trigger when the selected // Use a ref here because we don't want the effect to trigger when the selected
@ -74,5 +74,7 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => {
queryClient.resetQueries(); queryClient.resetQueries();
}, [selectedId, queryClient]); }, [selectedId, queryClient]);
return <AccountContext.Provider value={ret}>{children}</AccountContext.Provider>; return (
<AccountContext.Provider value={ret}>{children}</AccountContext.Provider>
);
}; };

View File

@ -1,6 +1,6 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { type Account, AccountP } from "~/models"; import { Account } from "~/models";
import { readValue, setCookie, storeValue } from "./settings"; import { readValue, setCookie, storeValue } from "./settings";
const writeAccounts = (accounts: Account[]) => { const writeAccounts = (accounts: Account[]) => {
@ -10,12 +10,12 @@ const writeAccounts = (accounts: Account[]) => {
if (!selected) return; if (!selected) return;
setCookie("account", selected); setCookie("account", selected);
// cookie used for images and videos since we can't add Authorization headers in img or video tags. // cookie used for images and videos since we can't add Authorization headers in img or video tags.
setCookie("X-Bearer", selected?.token.access_token); setCookie("X-Bearer", selected?.token);
} }
}; };
export const addAccount = (account: Account) => { export const addAccount = (account: Account) => {
const accounts = readValue("accounts", z.array(AccountP)) ?? []; const accounts = readValue("accounts", z.array(Account)) ?? [];
// Prevent the user from adding the same account twice. // Prevent the user from adding the same account twice.
if (accounts.find((x) => x.id === account.id)) { if (accounts.find((x) => x.id === account.id)) {
@ -30,7 +30,7 @@ export const addAccount = (account: Account) => {
}; };
export const removeAccounts = (filter: (acc: Account) => boolean) => { export const removeAccounts = (filter: (acc: Account) => boolean) => {
let accounts = readValue("accounts", z.array(AccountP)) ?? []; let accounts = readValue("accounts", z.array(Account)) ?? [];
accounts = accounts.filter((x) => !filter(x)); accounts = accounts.filter((x) => !filter(x));
if (!accounts.find((x) => x.selected) && accounts.length > 0) { if (!accounts.find((x) => x.selected) && accounts.length > 0) {
accounts[0].selected = true; accounts[0].selected = true;
@ -39,7 +39,7 @@ export const removeAccounts = (filter: (acc: Account) => boolean) => {
}; };
export const updateAccount = (id: string, account: Account) => { export const updateAccount = (id: string, account: Account) => {
const accounts = readValue("accounts", z.array(AccountP)) ?? []; const accounts = readValue("accounts", z.array(Account)) ?? [];
const idx = accounts.findIndex((x) => x.id === id); const idx = accounts.findIndex((x) => x.id === id);
if (idx === -1) return; if (idx === -1) return;

View File

@ -1,6 +1,11 @@
import {
DefaultTheme,
ThemeProvider as RNThemeProvider,
} from "@react-navigation/native";
import { HydrationBoundary, QueryClientProvider } from "@tanstack/react-query"; import { HydrationBoundary, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
// import { useUserTheme } from "@kyoo/models"; import { useColorScheme } from "react-native";
import { useTheme } from "yoshiki/native";
import { ThemeSelector } from "~/primitives/theme"; import { ThemeSelector } from "~/primitives/theme";
import { createQueryClient } from "~/query"; import { createQueryClient } from "~/query";
import { AccountProvider } from "./account-provider"; import { AccountProvider } from "./account-provider";
@ -9,41 +14,68 @@ import { ErrorProvider } from "./error-provider";
import { NativeProviders } from "./native-providers"; import { NativeProviders } from "./native-providers";
import { TranslationsProvider } from "./translations.native"; import { TranslationsProvider } from "./translations.native";
function getServerData(key: string) {} function getServerData(_key: string) {}
const QueryProvider = ({ children }: { children: ReactNode }) => { const QueryProvider = ({ children }: { children: ReactNode }) => {
const [queryClient] = useState(() => createQueryClient()); const [queryClient] = useState(() => createQueryClient());
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<HydrationBoundary state={getServerData("queryState")}>{children}</HydrationBoundary> <HydrationBoundary state={getServerData("queryState")}>
{children}
</HydrationBoundary>
</QueryClientProvider> </QueryClientProvider>
); );
}; };
const ThemeProvider = ({ children }: { children: ReactNode }) => { const ThemeProvider = ({ children }: { children: ReactNode }) => {
// TODO: change "auto" and use the user's theme cookie // we can't use "auto" here because it breaks the `RnThemeProvider`
const userTheme = "auto"; //useUserTheme("auto"); const userTheme = useColorScheme();
return ( return (
<ThemeSelector theme={userTheme} font={{ normal: "inherit" }}> <ThemeSelector theme={userTheme ?? "light"} font={{ normal: "inherit" }}>
{children} {children}
</ThemeSelector> </ThemeSelector>
); );
}; };
const RnTheme = ({ children }: { children: ReactNode }) => {
const theme = useTheme();
return (
<RNThemeProvider
value={{
dark: theme.mode === "dark",
colors: {
primary: theme.accent,
card: theme.variant.background,
text: theme.paragraph,
border: theme.background,
notification: theme.background,
background: theme.background,
},
fonts: DefaultTheme.fonts,
}}
>
{children}
</RNThemeProvider>
);
};
export const Providers = ({ children }: { children: ReactNode }) => { export const Providers = ({ children }: { children: ReactNode }) => {
return ( return (
<QueryProvider> <QueryProvider>
<ThemeProvider> <ThemeProvider>
<ErrorProvider> <RnTheme>
<AccountProvider> <ErrorProvider>
<TranslationsProvider> <AccountProvider>
<NativeProviders> <TranslationsProvider>
<ErrorConsumer scope="root">{children}</ErrorConsumer> <NativeProviders>
</NativeProviders> <ErrorConsumer scope="root">{children}</ErrorConsumer>
</TranslationsProvider> </NativeProviders>
</AccountProvider> </TranslationsProvider>
</ErrorProvider> </AccountProvider>
</ErrorProvider>
</RnTheme>
</ThemeProvider> </ThemeProvider>
</QueryProvider> </QueryProvider>
); );

View File

@ -0,0 +1,17 @@
import { useFetch } from "~/query";
import { useAccount } from "./account-context";
// import { ServerInfoP } from "~/models/resources/server-info";
// export const useHasPermission = (perms?: string[]) => {
// const account = useAccount();
// const { data } = useFetch({
// path: ["info"],
// parser: ServerInfoP,
// });
//
// if (!perms || !perms[0]) return true;
//
// const available = account?.permissions ?? data?.guestPermissions;
// if (!available) return false;
// return perms.every((perm) => available.includes(perm));
// };

View File

@ -1,6 +1,6 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import { MMKV, useMMKVString } from "react-native-mmkv"; import { MMKV, useMMKVString } from "react-native-mmkv";
import type { z, ZodType } from "zod/v4"; import type { ZodType, z } from "zod/v4";
import { getServerData } from "~/utils"; import { getServerData } from "~/utils";
export const storage = new MMKV(); export const storage = new MMKV();
@ -20,7 +20,9 @@ export const setCookie = (key: string, val?: unknown) => {
const d = new Date(); const d = new Date();
// A year // A year
d.setTime(d.getTime() + 365 * 24 * 60 * 60 * 1000); d.setTime(d.getTime() + 365 * 24 * 60 * 60 * 1000);
const expires = value ? `expires=${d.toUTCString()}` : "expires=Thu, 01 Jan 1970 00:00:01 GMT"; const expires = value
? `expires=${d.toUTCString()}`
: "expires=Thu, 01 Jan 1970 00:00:01 GMT";
document.cookie = `${key}=${value};${expires};path=/;samesite=strict`; document.cookie = `${key}=${value};${expires};path=/;samesite=strict`;
}; };

View File

@ -2,8 +2,8 @@ import { LegendList } from "@legendapp/list";
import { type ComponentType, type ReactElement, useRef } from "react"; import { type ComponentType, type ReactElement, useRef } from "react";
import { type Breakpoint, HR, useBreakpointMap } from "~/primitives"; import { type Breakpoint, HR, useBreakpointMap } from "~/primitives";
import { useSetError } from "~/providers/error-provider"; import { useSetError } from "~/providers/error-provider";
import { type QueryIdentifier, useInfiniteFetch } from "~/query"; import { ErrorView } from "~/ui/errors";
import { ErrorView } from "../ui/errors"; import { type QueryIdentifier, useInfiniteFetch } from "./query";
export type Layout = { export type Layout = {
numColumns: Breakpoint<number>; numColumns: Breakpoint<number>;

View File

@ -21,18 +21,22 @@ const cleanSlash = (str: string | null, keepFirst = false) => {
return str.replace(/^\/|\/$/g, ""); return str.replace(/^\/|\/$/g, "");
}; };
const queryFn = async <Parser extends z.ZodTypeAny>(context: { export const queryFn = async <Parser extends z.ZodTypeAny>(context: {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
url: string; url: string;
body?: object; body?: object;
formData?: FormData; formData?: FormData;
plainText?: boolean; plainText?: boolean;
authToken: string | null; authToken: string | null;
parser?: Parser; parser: Parser | null;
signal?: AbortSignal; signal?: AbortSignal;
}): Promise<z.infer<Parser>> => { }): Promise<z.infer<Parser>> => {
if (Platform.OS === "web" && typeof window === "undefined" && context.url.startsWith("/api")) if (
context.url = `${ssrApiUrl}/${context.url.substring(4)}`; Platform.OS === "web" &&
typeof window === "undefined" &&
context.url.startsWith("/")
)
context.url = `${ssrApiUrl}${context.url}`;
let resp: Response; let resp: Response;
try { try {
resp = await fetch(context.url, { resp = await fetch(context.url, {
@ -44,7 +48,9 @@ const queryFn = async <Parser extends z.ZodTypeAny>(context: {
? context.formData ? context.formData
: undefined, : undefined,
headers: { headers: {
...(context.authToken ? { Authorization: `Bearer ${context.authToken}` } : {}), ...(context.authToken
? { Authorization: `Bearer ${context.authToken}` }
: {}),
...("body" in context ? { "Content-Type": "application/json" } : {}), ...("body" in context ? { "Content-Type": "application/json" } : {}),
}, },
signal: context.signal, signal: context.signal,
@ -53,7 +59,10 @@ const queryFn = async <Parser extends z.ZodTypeAny>(context: {
if (typeof e === "object" && e && "name" in e && e.name === "AbortError") if (typeof e === "object" && e && "name" in e && e.name === "AbortError")
throw { message: "Aborted", status: "aborted" } as KyooError; throw { message: "Aborted", status: "aborted" } as KyooError;
console.log("Fetch error", e, context.url); console.log("Fetch error", e, context.url);
throw { message: "Could not reach Kyoo's server.", status: "aborted" } as KyooError; throw {
message: "Could not reach Kyoo's server.",
status: "aborted",
} as KyooError;
} }
if (resp.status === 404) { if (resp.status === 404) {
throw { message: "Resource not found.", status: 404 } as KyooError; throw { message: "Resource not found.", status: 404 } as KyooError;
@ -67,7 +76,11 @@ const queryFn = async <Parser extends z.ZodTypeAny>(context: {
data = { message: error } as KyooError; data = { message: error } as KyooError;
} }
data.status = resp.status; data.status = resp.status;
console.log(`Invalid response (${context.method ?? "GET"} ${context.url}):`, data, resp.status); console.log(
`Invalid response (${context.method ?? "GET"} ${context.url}):`,
data,
resp.status,
);
throw data as KyooError; throw data as KyooError;
} }
@ -80,12 +93,22 @@ const queryFn = async <Parser extends z.ZodTypeAny>(context: {
data = await resp.json(); data = await resp.json();
} catch (e) { } catch (e) {
console.error("Invalid json from kyoo", e); console.error("Invalid json from kyoo", e);
throw { message: "Invalid response from kyoo", status: "json" } as KyooError; throw {
message: "Invalid response from kyoo",
status: "json",
} as KyooError;
} }
if (!context.parser) return data as any; if (!context.parser) return data as any;
const parsed = await context.parser.safeParseAsync(data); const parsed = await context.parser.safeParseAsync(data);
if (!parsed.success) { if (!parsed.success) {
console.log("Url: ", context.url, " Response: ", resp.status, " Parse error: ", parsed.error); console.log(
"Url: ",
context.url,
" Response: ",
resp.status,
" Parse error: ",
parsed.error,
);
throw { throw {
status: "parse", status: "parse",
message: message:
@ -109,9 +132,11 @@ export const createQueryClient = () =>
}); });
export type QueryIdentifier<T = unknown> = { export type QueryIdentifier<T = unknown> = {
parser: z.ZodType<T>; parser: z.ZodType<T> | null;
path: (string | undefined)[]; path: (string | undefined)[];
params?: { [query: string]: boolean | number | string | string[] | undefined }; params?: {
[query: string]: boolean | number | string | string[] | undefined;
};
infinite?: boolean; infinite?: boolean;
placeholderData?: T | (() => T); placeholderData?: T | (() => T);
@ -124,7 +149,9 @@ export type QueryIdentifier<T = unknown> = {
const toQueryKey = (query: { const toQueryKey = (query: {
apiUrl: string; apiUrl: string;
path: (string | undefined)[]; path: (string | undefined)[];
params?: { [query: string]: boolean | number | string | string[] | undefined }; params?: {
[query: string]: boolean | number | string | string[] | undefined;
};
}) => { }) => {
return [ return [
cleanSlash(query.apiUrl, true), cleanSlash(query.apiUrl, true),
@ -172,7 +199,7 @@ export const useInfiniteFetch = <Data,>(query: QueryIdentifier<Data>) => {
queryFn: (ctx) => queryFn: (ctx) =>
queryFn({ queryFn({
url: (ctx.pageParam as string) ?? keyToUrl(key), url: (ctx.pageParam as string) ?? keyToUrl(key),
parser: Paged(query.parser), parser: query.parser ? Paged(query.parser) : null,
signal: ctx.signal, signal: ctx.signal,
authToken: authToken ?? null, authToken: authToken ?? null,
...query.options, ...query.options,
@ -207,7 +234,7 @@ export const prefetch = async (...queries: QueryIdentifier[]) => {
queryFn: (ctx) => queryFn: (ctx) =>
queryFn({ queryFn({
url: keyToUrl(key), url: keyToUrl(key),
parser: Paged(query.parser), parser: query.parser ? Paged(query.parser) : null,
signal: ctx.signal, signal: ctx.signal,
authToken: authToken ?? null, authToken: authToken ?? null,
...query.options, ...query.options,
@ -235,7 +262,9 @@ export const prefetch = async (...queries: QueryIdentifier[]) => {
type MutationParams = { type MutationParams = {
method?: "POST" | "PUT" | "DELETE"; method?: "POST" | "PUT" | "DELETE";
path?: string[]; path?: string[];
params?: { [query: string]: boolean | number | string | string[] | undefined }; params?: {
[query: string]: boolean | number | string | string[] | undefined;
};
body?: object; body?: object;
}; };
@ -261,6 +290,7 @@ export const useMutation = <T = void>({
url: keyToUrl(toQueryKey({ apiUrl, path, params })), url: keyToUrl(toQueryKey({ apiUrl, path, params })),
body, body,
authToken, authToken,
parser: null,
}); });
}, },
onSuccess: invalidate onSuccess: invalidate

View File

@ -45,7 +45,7 @@ BrowsePage.query = (
): QueryIdentifier<Show> => { ): QueryIdentifier<Show> => {
return { return {
parser: Show, parser: Show,
path: ["shows"], path: ["api", "shows"],
infinite: true, infinite: true,
params: { params: {
sort: sortBy ? `${sortOrd === "desc" ? "-" : ""}${sortBy}` : "name", sort: sortBy ? `${sortOrd === "desc" ? "-" : ""}${sortBy}` : "name",

View File

@ -5,8 +5,8 @@ import { OfflineView } from "./offline";
// import { SetupPage } from "./setup"; // import { SetupPage } from "./setup";
import { Unauthorized } from "./unauthorized"; import { Unauthorized } from "./unauthorized";
export * from "./error";
export * from "./empty"; export * from "./empty";
export * from "./error";
export type ErrorHandler = { export type ErrorHandler = {
view: FC<{ error: KyooError; retry: () => void }>; view: FC<{ error: KyooError; retry: () => void }>;
@ -16,6 +16,6 @@ export type ErrorHandler = {
export const errorHandlers: Record<string, ErrorHandler> = { export const errorHandlers: Record<string, ErrorHandler> = {
unauthorized: { view: Unauthorized, forbid: "app" }, unauthorized: { view: Unauthorized, forbid: "app" },
// setup: { view: SetupPage, forbid: "setup" }, // setup: { view: SetupPage, forbid: "setup" },
connection: { view: ConnectionError }, connection: { view: ConnectionError, forbid: "login" },
offline: { view: OfflineView }, offline: { view: OfflineView },
}; };

View File

@ -1,9 +1,8 @@
import { imageFn } from "@kyoo/models";
import { ts } from "@kyoo/primitives";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { ImageBackground, type ImageProps, Platform, ScrollView, View } from "react-native"; import { ImageBackground, ScrollView, View } from "react-native";
import Svg, { type SvgProps, Path } from "react-native-svg"; import Svg, { Path, type SvgProps } from "react-native-svg";
import { type Stylable, min, px, useYoshiki, vh } from "yoshiki/native"; import { min, px, type Stylable, useYoshiki, vh } from "yoshiki/native";
import { ts } from "~/primitives";
const SvgBlob = (props: SvgProps) => { const SvgBlob = (props: SvgProps) => {
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
@ -27,18 +26,9 @@ export const FormPage = ({
}: { children: ReactNode; apiUrl?: string } & Stylable) => { }: { children: ReactNode; apiUrl?: string } & Stylable) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const src = apiUrl ? `${apiUrl}/items/random/thumbnail` : imageFn("/items/random/thumbnail");
const nativeProps = Platform.select<Partial<ImageProps>>({
web: {
defaultSource: { uri: src },
},
default: {},
});
return ( return (
<ImageBackground <ImageBackground
source={{ uri: src }} source={{ uri: `${apiUrl ?? ""}/api/shows/random/thumbnail` }}
{...nativeProps}
{...css({ {...css({
flexDirection: "row", flexDirection: "row",
flexGrow: 1, flexGrow: 1,

View File

@ -1,4 +1,3 @@
export { LoginPage } from "./login"; export { LoginPage } from "./login";
export { RegisterPage } from "./register"; export { RegisterPage } from "./register";
export { ServerUrlPage } from "./server-url"; export { ServerUrlPage } from "./server-url";
export { OidcCallbackPage } from "./oidc";

View File

@ -1,4 +1,5 @@
import { type Account, type KyooErrors, TokenP, UserP, getCurrentApiUrl } from "~/models"; import { z } from "zod/v4";
import { type Account, type KyooError, User } from "~/models";
import { addAccount, removeAccounts } from "~/providers/account-store"; import { addAccount, removeAccounts } from "~/providers/account-store";
import { queryFn } from "~/query"; import { queryFn } from "~/query";
@ -8,70 +9,83 @@ type Result<A, B> =
export const login = async ( export const login = async (
action: "register" | "login", action: "register" | "login",
{ apiUrl, ...body }: { username: string; password: string; email?: string; apiUrl?: string }, {
apiUrl,
...body
}: {
login?: string;
username?: string;
password: string;
email?: string;
apiUrl: string | null;
},
): Promise<Result<Account, string>> => { ): Promise<Result<Account, string>> => {
if (!apiUrl || apiUrl.length === 0) apiUrl = getCurrentApiUrl()!; apiUrl ??= "";
try { try {
const controller = new AbortController(); const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000); setTimeout(() => controller.abort(), 5_000);
const token = await queryFn( const { token } = await queryFn({
{ method: "POST",
path: ["auth", action], url: `${apiUrl}/auth/${action === "login" ? "sessions" : "users"}`,
method: "POST", body,
body, authToken: null,
authenticated: false, signal: controller.signal,
apiUrl, parser: z.object({ token: z.string() }),
signal: controller.signal, });
},
TokenP,
);
const user = await queryFn({ const user = await queryFn({
method: "GET", method: "GET",
path: ["auth", "me"], url: `${apiUrl}/auth/users/me`,
apiUrl, authToken: token,
authToken: token.access_token, parser: User,
parser: UserP,
}); });
const account: Account = { ...user, apiUrl: apiUrl, token, selected: true }; const account: Account = { ...user, apiUrl, token, selected: true };
addAccount(account); addAccount(account);
return { ok: true, value: account }; return { ok: true, value: account };
} catch (e) { } catch (e) {
console.error(action, e); console.error(action, e);
return { ok: false, error: (e as KyooErrors).errors[0] }; return { ok: false, error: (e as KyooError).message };
} }
}; };
export const oidcLogin = async (provider: string, code: string, apiUrl?: string) => { // export const oidcLogin = async (
if (!apiUrl || apiUrl.length === 0) apiUrl = getCurrentApiUrl()!; // provider: string,
try { // code: string,
const token = await queryFn( // apiUrl?: string,
{ // ) => {
path: ["auth", "callback", provider, `?code=${code}`], // if (!apiUrl || apiUrl.length === 0) apiUrl = getCurrentApiUrl()!;
method: "POST", // try {
authenticated: false, // const token = await queryFn(
apiUrl, // {
}, // path: ["auth", "callback", provider, `?code=${code}`],
TokenP, // method: "POST",
); // authenticated: false,
const user = await queryFn( // apiUrl,
{ path: ["auth", "me"], method: "GET", apiUrl }, // },
UserP, // TokenP,
`Bearer ${token.access_token}`, // );
); // const user = await queryFn(
const account: Account = { ...user, apiUrl: apiUrl, token, selected: true }; // { path: ["auth", "me"], method: "GET", apiUrl },
addAccount(account); // UserP,
return { ok: true, value: account }; // `Bearer ${token.access_token}`,
} catch (e) { // );
console.error("oidcLogin", e); // const account: Account = { ...user, apiUrl: apiUrl, token, selected: true };
return { ok: false, error: (e as KyooErrors).errors[0] }; // addAccount(account);
} // return { ok: true, value: account };
}; // } catch (e) {
// console.error("oidcLogin", e);
// return { ok: false, error: (e as KyooErrors).errors[0] };
// }
// };
export const logout = () => { export const logout = () => {
removeAccounts((x) => x.selected); removeAccounts((x) => x.selected);
}; };
export const deleteAccount = async () => { export const deleteAccount = async () => {
await queryFn({ path: ["auth", "me"], method: "DELETE" }); // await queryFn({
// method: "DELETE",
// url: "auth/users/me",
// parser: null,
// });
logout(); logout();
}; };

View File

@ -1,89 +1,75 @@
import { useEffect, useState } from "react"; import { useRouter } from "expo-router";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { percent, px, useYoshiki } from "yoshiki/native"; import { percent, px, useYoshiki } from "yoshiki/native";
import { type QueryPage, login } from "~/models";
import { A, Button, H1, Input, P, ts } from "~/primitives"; import { A, Button, H1, Input, P, ts } from "~/primitives";
import { useQueryState } from "~/utils";
import { FormPage } from "./form"; import { FormPage } from "./form";
import { OidcLogin } from "./oidc"; import { login } from "./logic";
import { PasswordInput } from "./password-input"; import { PasswordInput } from "./password-input";
import { ServerUrlPage } from "./server-url";
export const LoginPage: QueryPage<{ apiUrl?: string; error?: string }> = ({ export const LoginPage = () => {
apiUrl, const [apiUrl] = useQueryState("apiUrl", null);
error: initialError,
}) => {
const { data } = useFetch({
path: ["info"],
parser: ServerInfoP,
});
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | undefined>(initialError); const [error, setError] = useState<string | undefined>();
const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const { css } = useYoshiki(); const { css } = useYoshiki();
const router = useRouter();
useEffect(() => { if (Platform.OS !== "web" && !apiUrl) return <ServerUrlPage />;
if (!apiUrl && Platform.OS !== "web")
router.replace("/server-url", undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false },
});
}, [apiUrl, router]);
return ( return (
<FormPage apiUrl={apiUrl}> <FormPage apiUrl={apiUrl!}>
<H1>{t("login.login")}</H1> <H1>{t("login.login")}</H1>
<OidcLogin apiUrl={apiUrl} hideOr={!data?.passwordLoginEnabled} /> {/* <OidcLogin apiUrl={apiUrl} hideOr={!data?.passwordLoginEnabled} /> */}
{data?.passwordLoginEnabled && ( {/* {data?.passwordLoginEnabled && ( */}
<> {/* <> */}
<P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P> <P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.username")}</P>
<Input <Input
autoComplete="username" autoComplete="username"
variant="big" variant="big"
onChangeText={(value) => setUsername(value)} onChangeText={(value) => setUsername(value)}
autoCapitalize="none" autoCapitalize="none"
/> />
<P {...css({ paddingLeft: ts(1) })}>{t("login.password")}</P> <P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.password")}</P>
<PasswordInput <PasswordInput
autoComplete="password" autoComplete="password"
variant="big" variant="big"
onChangeText={(value) => setPassword(value)} onChangeText={(value) => setPassword(value)}
/> />
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>} {error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>}
<Button <Button
text={t("login.login")} text={t("login.login")}
onPress={async () => { onPress={async () => {
const { error } = await login("login", { const { error } = await login("login", {
username, login: username,
password, password,
apiUrl, apiUrl,
}); });
setError(error); setError(error);
if (error) return; if (error) return;
router.replace("/", undefined, { router.replace("/");
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, }}
}); {...css({
}} m: ts(1),
{...css({ width: px(250),
m: ts(1), maxWidth: percent(100),
width: px(250), alignSelf: "center",
maxWidth: percent(100), mY: ts(3),
alignSelf: "center", })}
mY: ts(3), />
})} {/* </> */}
/> {/* )} */}
</>
)}
<P> <P>
<Trans i18nKey="login.or-register"> <Trans i18nKey="login.or-register">
Dont have an account? <A href={{ pathname: "/register", query: { apiUrl } }}>Register</A> Dont have an account?{" "}
. <A href={`/register?apiUrl=${apiUrl}`}>Register</A>.
</Trans> </Trans>
</P> </P>
</FormPage> </FormPage>
); );
}; };
LoginPage.getFetchUrls = () => [OidcLogin.query()];

View File

@ -1,8 +1,8 @@
import { IconButton, Input } from "@kyoo/primitives";
import Visibility from "@material-symbols/svg-400/rounded/visibility-fill.svg";
import VisibilityOff from "@material-symbols/svg-400/rounded/visibility_off-fill.svg"; import VisibilityOff from "@material-symbols/svg-400/rounded/visibility_off-fill.svg";
import Visibility from "@material-symbols/svg-400/rounded/visibility-fill.svg";
import { type ComponentProps, useState } from "react"; import { type ComponentProps, useState } from "react";
import { px, useYoshiki } from "yoshiki/native"; import { px, useYoshiki } from "yoshiki/native";
import { IconButton, Input } from "~/primitives";
export const PasswordInput = (props: ComponentProps<typeof Input>) => { export const PasswordInput = (props: ComponentProps<typeof Input>) => {
const { css } = useYoshiki(); const { css } = useYoshiki();

View File

@ -1,21 +1,17 @@
import { type QueryPage, login } from "@kyoo/models"; import { useRouter } from "expo-router";
import { A, Button, H1, Input, P, ts } from "@kyoo/primitives"; import { useState } from "react";
import { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useRouter } from "solito/router";
import { percent, px, useYoshiki } from "yoshiki/native"; import { percent, px, useYoshiki } from "yoshiki/native";
import { DefaultLayout } from "../../../packages/ui/src/layout"; import { A, Button, H1, Input, P, ts } from "~/primitives";
import { useQueryState } from "~/utils";
import { FormPage } from "./form"; import { FormPage } from "./form";
import { OidcLogin } from "./oidc"; import { login } from "./logic";
import { PasswordInput } from "./password-input"; import { PasswordInput } from "./password-input";
import { ServerUrlPage } from "./server-url";
export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => { export const RegisterPage = () => {
const { data } = useFetch({ const [apiUrl] = useQueryState("apiUrl", null);
path: ["info"],
parser: ServerInfoP,
});
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@ -26,77 +22,78 @@ export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { css } = useYoshiki(); const { css } = useYoshiki();
useEffect(() => { if (Platform.OS !== "web" && !apiUrl) return <ServerUrlPage />;
if (!apiUrl && Platform.OS !== "web")
router.replace("/server-url", undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false },
});
}, [apiUrl, router]);
return ( return (
<FormPage apiUrl={apiUrl}> <FormPage apiUrl={apiUrl!}>
<H1>{t("login.register")}</H1> <H1>{t("login.register")}</H1>
<OidcLogin apiUrl={apiUrl} hideOr={!data?.passwordLoginEnabled} /> {/* <OidcLogin apiUrl={apiUrl} hideOr={!data?.passwordLoginEnabled} /> */}
{data?.registrationEnabled && ( {/* {data?.registrationEnabled && ( */}
<> {/* <> */}
<P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P> <P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.username")}</P>
<Input <Input
autoComplete="username" autoComplete="username"
variant="big" variant="big"
onChangeText={(value) => setUsername(value)} onChangeText={(value) => setUsername(value)}
/> />
<P {...css({ paddingLeft: ts(1) })}>{t("login.email")}</P> <P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.email")}</P>
<Input autoComplete="email" variant="big" onChangeText={(value) => setEmail(value)} /> <Input
autoComplete="email"
variant="big"
onChangeText={(value) => setEmail(value)}
/>
<P {...css({ paddingLeft: ts(1) })}>{t("login.password")}</P> <P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.password")}</P>
<PasswordInput <PasswordInput
autoComplete="password-new" autoComplete="password-new"
variant="big" variant="big"
onChangeText={(value) => setPassword(value)} onChangeText={(value) => setPassword(value)}
/> />
<P {...css({ paddingLeft: ts(1) })}>{t("login.confirm")}</P> <P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.confirm")}</P>
<PasswordInput <PasswordInput
autoComplete="password-new" autoComplete="password-new"
variant="big" variant="big"
onChangeText={(value) => setConfirm(value)} onChangeText={(value) => setConfirm(value)}
/> />
{password !== confirm && ( {password !== confirm && (
<P {...css({ color: (theme) => theme.colors.red })}>{t("login.password-no-match")}</P> <P {...css({ color: (theme) => theme.colors.red })}>
)} {t("login.password-no-match")}
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>} </P>
<Button
text={t("login.register")}
disabled={password !== confirm}
onPress={async () => {
const { error } = await login("register", { email, username, password, apiUrl });
setError(error);
if (error) return;
router.replace("/", undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false },
});
}}
{...css({
m: ts(1),
width: px(250),
maxWidth: percent(100),
alignSelf: "center",
mY: ts(3),
})}
/>
</>
)} )}
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>}
<Button
text={t("login.register")}
disabled={password !== confirm}
onPress={async () => {
const { error } = await login("register", {
email,
username,
password,
apiUrl,
});
setError(error);
if (error) return;
router.replace("/");
}}
{...css({
m: ts(1),
width: px(250),
maxWidth: percent(100),
alignSelf: "center",
mY: ts(3),
})}
/>
{/* </> */}
{/* )} */}
<P> <P>
<Trans i18nKey="login.or-login"> <Trans i18nKey="login.or-login">
Have an account already? <A href={{ pathname: "/login", query: { apiUrl } }}>Log in</A>. Have an account already?{" "}
<A href={`/login?apiUrl=${apiUrl}`}>Log in</A>.
</Trans> </Trans>
</P> </P>
</FormPage> </FormPage>
); );
}; };
RegisterPage.getFetchUrls = () => [OidcLogin.query()];
RegisterPage.isPublic = true;
RegisterPage.getLayout = DefaultLayout;

View File

@ -1,28 +1,23 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ImageBackground, Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { type Theme, percent, useYoshiki } from "yoshiki/native"; import { type Theme, useYoshiki } from "yoshiki/native";
import { type ServerInfo, ServerInfoP } from "~/models"; import { Button, H1, Input, Link, P, ts } from "~/primitives";
import { Button, H1, HR, Input, Link, P, ts } from "~/primitives"; import { type QueryIdentifier, useFetch } from "~/query";
import { DefaultLayout } from "../../../packages/ui/src/layout";
export const cleanApiUrl = (apiUrl: string) => { export const cleanApiUrl = (apiUrl: string) => {
if (Platform.OS === "web") return undefined; if (Platform.OS === "web") return undefined;
if (!/https?:\/\//.test(apiUrl)) apiUrl = `http://${apiUrl}`; if (!/https?:\/\//.test(apiUrl)) apiUrl = `http://${apiUrl}`;
apiUrl = apiUrl.replace(/\/$/, ""); return apiUrl.replace(/\/$/, "");
return `${apiUrl}/api`;
}; };
const query: QueryIdentifier<ServerInfo> = { export const ServerUrlPage = () => {
path: ["info"],
parser: ServerInfoP,
};
export const ServerUrlPage: QueryPage = () => {
const [_apiUrl, setApiUrl] = useState(""); const [_apiUrl, setApiUrl] = useState("");
const apiUrl = cleanApiUrl(_apiUrl); const apiUrl = cleanApiUrl(_apiUrl);
const { data, error } = useFetch({ ...query, options: { apiUrl, authenticated: false } }); const { data, error } = useFetch({
const router = useRouter(); ...ServerUrlPage.query,
options: { apiUrl, authToken: null },
});
const { t } = useTranslation(); const { t } = useTranslation();
const { css } = useYoshiki(); const { css } = useYoshiki();
@ -36,48 +31,56 @@ export const ServerUrlPage: QueryPage = () => {
> >
<H1>{t("login.server")}</H1> <H1>{t("login.server")}</H1>
<View {...css({ justifyContent: "center" })}> <View {...css({ justifyContent: "center" })}>
<Input variant="big" onChangeText={setApiUrl} autoCorrect={false} autoCapitalize="none" /> <Input
variant="big"
onChangeText={setApiUrl}
autoCorrect={false}
autoCapitalize="none"
/>
{!data && ( {!data && (
<P {...css({ color: (theme: Theme) => theme.colors.red, alignSelf: "center" })}> <P
{error?.errors[0] ?? t("misc.loading")} {...css({
color: (theme: Theme) => theme.colors.red,
alignSelf: "center",
})}
>
{error?.message ?? t("misc.loading")}
</P> </P>
)} )}
</View> </View>
<View {...css({ marginTop: ts(5) })}> <View {...css({ marginTop: ts(5) })}>
<View {...css({ flexDirection: "row", width: percent(100), alignItems: "center" })}> {/* <View {...css({ flexDirection: "row", width: percent(100), alignItems: "center" })}> */}
{data && {/* {data && */}
Object.values(data.oidc).map((x) => ( {/* Object.values(data.oidc).map((x) => ( */}
<Link {/* <Link */}
key={x.displayName} {/* key={x.displayName} */}
href={{ pathname: x.link, query: { apiUrl } }} {/* href={{ pathname: x.link, query: { apiUrl } }} */}
{...css({ justifyContent: "center" })} {/* {...css({ justifyContent: "center" })} */}
> {/* > */}
{x.logoUrl != null ? ( {/* {x.logoUrl != null ? ( */}
<ImageBackground {/* <ImageBackground */}
source={{ uri: x.logoUrl }} {/* source={{ uri: x.logoUrl }} */}
{...css({ width: ts(3), height: ts(3), margin: ts(1) })} {/* {...css({ width: ts(3), height: ts(3), margin: ts(1) })} */}
/> {/* /> */}
) : ( {/* ) : ( */}
t("login.via", { provider: x.displayName }) {/* t("login.via", { provider: x.displayName }) */}
)} {/* )} */}
</Link> {/* </Link> */}
))} {/* ))} */}
</View> {/* </View> */}
<HR /> {/* <HR /> */}
<View {...css({ flexDirection: "row", gap: ts(2) })}> <View {...css({ flexDirection: "row", gap: ts(2) })}>
<Button <Button
text={t("login.login")} text={t("login.login")}
onPress={() => { as={Link}
router.push(`/login?apiUrl=${apiUrl}`); href={`/login?apiUrl=${apiUrl}`}
}}
disabled={data == null} disabled={data == null}
{...css({ flexGrow: 1, flexShrink: 1 })} {...css({ flexGrow: 1, flexShrink: 1 })}
/> />
<Button <Button
text={t("login.register")} text={t("login.register")}
onPress={() => { as={Link}
router.push(`/register?apiUrl=${apiUrl}`); href={`/register?apiUrl=${apiUrl}`}
}}
disabled={data == null} disabled={data == null}
{...css({ flexGrow: 1, flexShrink: 1 })} {...css({ flexGrow: 1, flexShrink: 1 })}
/> />
@ -88,4 +91,7 @@ export const ServerUrlPage: QueryPage = () => {
); );
}; };
ServerUrlPage.getLayout = DefaultLayout; ServerUrlPage.query = {
path: ["api", "health"],
parser: null,
} satisfies QueryIdentifier;

View File

@ -4,15 +4,20 @@ import Login from "@material-symbols/svg-400/rounded/login.svg";
import Logout from "@material-symbols/svg-400/rounded/logout.svg"; import Logout from "@material-symbols/svg-400/rounded/logout.svg";
import Search from "@material-symbols/svg-400/rounded/search-fill.svg"; import Search from "@material-symbols/svg-400/rounded/search-fill.svg";
import Settings from "@material-symbols/svg-400/rounded/settings.svg"; import Settings from "@material-symbols/svg-400/rounded/settings.svg";
import type { ReactElement, Ref } from "react"; import type { Ref } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, type TextInput, type TextInputProps, View, type ViewProps } from "react-native"; import {
import { type Stylable, percent, useYoshiki } from "yoshiki/native"; Platform,
type TextInput,
type TextInputProps,
View,
type ViewProps,
} from "react-native";
import { type Theme, useYoshiki } from "yoshiki/native";
import { import {
A, A,
Avatar, Avatar,
HR, HR,
Header,
IconButton, IconButton,
Input, Input,
Link, Link,
@ -21,29 +26,30 @@ import {
tooltip, tooltip,
ts, ts,
} from "~/primitives"; } from "~/primitives";
import { useAccount, useAccounts, useHasPermission } from "~/providers/account-context"; import { useAccount, useAccounts } from "~/providers/account-context";
import { logout } from "~/ui/login/logic"; import { logout } from "~/ui/login/logic";
import { useQueryState } from "~/utils"; import { useQueryState } from "~/utils";
// import { AdminPage } from "../../../packages/ui/src/admin/packages/ui/src/admin";
import { KyooLongLogo } from "./icon"; import { KyooLongLogo } from "./icon";
export const NavbarTitle = (props: Stylable & { onLayout?: ViewProps["onLayout"] }) => { export const NavbarTitle = (props: { onLayout?: ViewProps["onLayout"] }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { css } = useYoshiki();
return ( return (
<A <A
href="/" href="/"
aria-label={t("navbar.home")} aria-label={t("navbar.home")}
{...tooltip(t("navbar.home"))} {...tooltip(t("navbar.home"))}
{...css({ fontSize: 0 }, props)} {...props}
> >
<KyooLongLogo /> <KyooLongLogo />
</A> </A>
); );
}; };
const SearchBar = ({ ref, ...props }: TextInputProps & { ref?: Ref<TextInput> }) => { const SearchBar = ({
ref,
...props
}: TextInputProps & { ref?: Ref<TextInput> }) => {
const { theme } = useYoshiki(); const { theme } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
const [query, setQuery] = useQueryState("q", ""); const [query, setQuery] = useQueryState("q", "");
@ -55,7 +61,11 @@ const SearchBar = ({ ref, ...props }: TextInputProps & { ref?: Ref<TextInput> })
onChangeText={setQuery} onChangeText={setQuery}
placeholder={t("navbar.search")} placeholder={t("navbar.search")}
placeholderTextColor={theme.contrast} placeholderTextColor={theme.contrast}
containerStyle={{ height: ts(4), flexShrink: 1, borderColor: (theme) => theme.contrast }} containerStyle={{
height: ts(4),
flexShrink: 1,
borderColor: (theme: Theme) => theme.contrast,
}}
{...tooltip(t("navbar.search"))} {...tooltip(t("navbar.search"))}
{...props} {...props}
/> />
@ -89,7 +99,11 @@ export const NavbarProfile = () => {
{accounts?.map((x) => ( {accounts?.map((x) => (
<Menu.Item <Menu.Item
key={x.id} key={x.id}
label={Platform.OS === "web" ? x.username : `${x.username} - ${getDisplayUrl(x.apiUrl)}`} label={
Platform.OS === "web"
? x.username
: `${x.username} - ${getDisplayUrl(x.apiUrl)}`
}
left={<Avatar placeholder={x.username} src={x.logo} />} left={<Avatar placeholder={x.username} src={x.logo} />}
selected={x.selected} selected={x.selected}
onSelect={() => x.select()} onSelect={() => x.select()}
@ -100,12 +114,24 @@ export const NavbarProfile = () => {
{!account ? ( {!account ? (
<> <>
<Menu.Item label={t("login.login")} icon={Login} href="/login" /> <Menu.Item label={t("login.login")} icon={Login} href="/login" />
<Menu.Item label={t("login.register")} icon={Register} href="/register" /> <Menu.Item
label={t("login.register")}
icon={Register}
href="/register"
/>
</> </>
) : ( ) : (
<> <>
<Menu.Item label={t("login.add-account")} icon={Login} href="/login" /> <Menu.Item
<Menu.Item label={t("login.logout")} icon={Logout} onSelect={logout} /> label={t("login.add-account")}
icon={Login}
href="/login"
/>
<Menu.Item
label={t("login.logout")}
icon={Logout}
onSelect={logout}
/>
</> </>
)} )}
</Menu> </Menu>
@ -117,7 +143,9 @@ export const NavbarRight = () => {
const isAdmin = false; //useHasPermission(AdminPage.requiredPermissions); const isAdmin = false; //useHasPermission(AdminPage.requiredPermissions);
return ( return (
<View {...css({ flexDirection: "row", alignItems: "center", flexShrink: 1 })}> <View
{...css({ flexDirection: "row", alignItems: "center", flexShrink: 1 })}
>
{Platform.OS === "web" ? ( {Platform.OS === "web" ? (
<SearchBar /> <SearchBar />
) : ( ) : (
@ -143,72 +171,78 @@ export const NavbarRight = () => {
); );
}; };
export const Navbar = ({ // export const Navbar = ({
left, // left,
right, // right,
background, // background,
...props // ...props
}: { // }: {
left?: ReactElement | null; // left?: ReactElement | null;
right?: ReactElement | null; // right?: ReactElement | null;
background?: ReactElement; // background?: ReactElement;
} & Stylable) => { // } & Stylable) => {
const { css } = useYoshiki(); // const { css } = useYoshiki();
const { t } = useTranslation(); // const { t } = useTranslation();
//
return ( // return (
<Header // <Header
{...css( // {...css(
{ // {
backgroundColor: (theme) => theme.accent, // backgroundColor: (theme) => theme.accent,
paddingX: ts(2), // paddingX: ts(2),
height: { xs: 48, sm: 64 }, // height: { xs: 48, sm: 64 },
flexDirection: "row", // flexDirection: "row",
justifyContent: { xs: "space-between", sm: "flex-start" }, // justifyContent: { xs: "space-between", sm: "flex-start" },
alignItems: "center", // alignItems: "center",
shadowColor: "#000", // shadowColor: "#000",
shadowOffset: { // shadowOffset: {
width: 0, // width: 0,
height: 4, // height: 4,
}, // },
shadowOpacity: 0.3, // shadowOpacity: 0.3,
shadowRadius: 4.65, // shadowRadius: 4.65,
elevation: 8, // elevation: 8,
zIndex: 1, // zIndex: 1,
}, // },
props, // props,
)} // )}
> // >
{background} // {background}
<View {...css({ flexDirection: "row", alignItems: "center", height: percent(100) })}> // <View
{left !== undefined ? ( // {...css({
left // flexDirection: "row",
) : ( // alignItems: "center",
<> // height: percent(100),
<NavbarTitle {...css({ marginX: ts(2) })} /> // })}
<A // >
href="/browse" // {left !== undefined ? (
{...css({ // left
textTransform: "uppercase", // ) : (
fontWeight: "bold", // <>
color: (theme) => theme.contrast, // <NavbarTitle {...css({ marginX: ts(2) })} />
})} // <A
> // href="/browse"
{t("navbar.browse")} // {...css({
</A> // textTransform: "uppercase",
</> // fontWeight: "bold",
)} // color: (theme) => theme.contrast,
</View> // })}
<View // >
{...css({ // {t("navbar.browse")}
flexGrow: 1, // </A>
flexShrink: 1, // </>
flexDirection: "row", // )}
display: { xs: "none", sm: "flex" }, // </View>
marginX: ts(2), // <View
})} // {...css({
/> // flexGrow: 1,
{right !== undefined ? right : <NavbarRight />} // flexShrink: 1,
</Header> // flexDirection: "row",
); // display: { xs: "none", sm: "flex" },
}; // marginX: ts(2),
// })}
// />
// {right !== undefined ? right : <NavbarRight />}
// </Header>
// );
// };