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"
options={{
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
name="browse"
options={{
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
name="downloads"
options={{
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>

View File

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

View File

@ -12,21 +12,15 @@ export default function Layout() {
<ErrorConsumer scope="app">
<Stack
screenOptions={{
// navigationBarColor: "transparent",
// navigationBarTranslucent: true,
// statusBarTranslucent: true,
// statusBarBackgroundColor: theme.accent,
headerTitle: () => <NavbarTitle />,
headerRight: () => <NavbarRight />,
contentStyle: {
backgroundColor: theme.background,
paddingLeft: insets.left,
paddingRight: insets.right,
},
headerStyle: {
backgroundColor: theme.accent,
},
headerTintColor: theme.colors.white,
}}
/>
</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 { zdate } from "./utils";
const defaultApiUrl = Platform.OS === "web" ? "/api" : null;
const currentApiUrl = atom<string | null>(defaultApiUrl);
export const getCurrentApiUrl = () => {
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 "./video";
export * from "./user";
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) => ({
...x,
low: `/images/${x.id}?quality=low`,
medium: `/images/${x.id}?quality=medium`,
high: `/images/${x.id}?quality=high`,
low: `/api/images/${x.id}?quality=low`,
medium: `/api/images/${x.id}?quality=medium`,
high: `/api/images/${x.id}?quality=high`,
}));
export type KImage = z.infer<typeof KImage>;

View File

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

View File

@ -1,13 +1,12 @@
import { createContext, useContext } from "react";
import { type Account, ServerInfoP, type Token } from "~/models";
import { useFetch } from "~/query";
import type { Account } from "~/models";
export const AccountContext = createContext<{
apiUrl: string;
authToken: string | null; //Token | null;
authToken: string | null;
selectedAccount: Account | null;
accounts: (Account & { select: () => void; remove: () => void })[];
}>({ apiUrl: "api", authToken: null, selectedAccount: null, accounts: [] });
}>({ apiUrl: "", authToken: null, selectedAccount: null, accounts: [] });
export const useToken = () => {
const { apiUrl, authToken } = useContext(AccountContext);
@ -23,17 +22,3 @@ export const useAccounts = () => {
const { accounts } = useContext(AccountContext);
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 { Platform } from "react-native";
import { z } from "zod/v4";
import { AccountP, UserP } from "~/models";
import { Account, User } from "~/models";
import { useFetch } from "~/query";
import { AccountContext } from "./account-context";
import { removeAccounts, updateAccount } from "./account-store";
@ -11,12 +11,12 @@ import { useStoreValue } from "./settings";
export const AccountProvider = ({ children }: { children: ReactNode }) => {
const [setError, clearError] = useSetError("account");
const accounts = useStoreValue("accounts", z.array(AccountP)) ?? [];
const accounts = useStoreValue("accounts", z.array(Account)) ?? [];
const ret = useMemo(() => {
const acc = accounts.find((x) => x.selected);
return {
apiUrl: Platform.OS === "web" ? "/api" : acc?.apiUrl!,
apiUrl: acc?.apiUrl ?? "",
authToken: acc?.token ?? null,
selectedAccount: acc ?? null,
accounts: accounts.map((account) => ({
@ -34,13 +34,13 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => {
data: user,
error: userError,
} = useFetch({
path: ["auth", "me"],
parser: UserP,
path: ["auth", "users", "me"],
parser: User,
placeholderData: ret.selectedAccount,
enabled: !!ret.selectedAccount,
options: {
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
@ -74,5 +74,7 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => {
queryClient.resetQueries();
}, [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 { z } from "zod/v4";
import { type Account, AccountP } from "~/models";
import { Account } from "~/models";
import { readValue, setCookie, storeValue } from "./settings";
const writeAccounts = (accounts: Account[]) => {
@ -10,12 +10,12 @@ const writeAccounts = (accounts: Account[]) => {
if (!selected) return;
setCookie("account", selected);
// 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) => {
const accounts = readValue("accounts", z.array(AccountP)) ?? [];
const accounts = readValue("accounts", z.array(Account)) ?? [];
// Prevent the user from adding the same account twice.
if (accounts.find((x) => x.id === account.id)) {
@ -30,7 +30,7 @@ export const addAccount = (account: Account) => {
};
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));
if (!accounts.find((x) => x.selected) && accounts.length > 0) {
accounts[0].selected = true;
@ -39,7 +39,7 @@ export const removeAccounts = (filter: (acc: Account) => boolean) => {
};
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);
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 { 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 { createQueryClient } from "~/query";
import { AccountProvider } from "./account-provider";
@ -9,41 +14,68 @@ import { ErrorProvider } from "./error-provider";
import { NativeProviders } from "./native-providers";
import { TranslationsProvider } from "./translations.native";
function getServerData(key: string) {}
function getServerData(_key: string) {}
const QueryProvider = ({ children }: { children: ReactNode }) => {
const [queryClient] = useState(() => createQueryClient());
return (
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={getServerData("queryState")}>{children}</HydrationBoundary>
<HydrationBoundary state={getServerData("queryState")}>
{children}
</HydrationBoundary>
</QueryClientProvider>
);
};
const ThemeProvider = ({ children }: { children: ReactNode }) => {
// TODO: change "auto" and use the user's theme cookie
const userTheme = "auto"; //useUserTheme("auto");
// we can't use "auto" here because it breaks the `RnThemeProvider`
const userTheme = useColorScheme();
return (
<ThemeSelector theme={userTheme} font={{ normal: "inherit" }}>
<ThemeSelector theme={userTheme ?? "light"} font={{ normal: "inherit" }}>
{children}
</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 }) => {
return (
<QueryProvider>
<ThemeProvider>
<ErrorProvider>
<AccountProvider>
<TranslationsProvider>
<NativeProviders>
<ErrorConsumer scope="root">{children}</ErrorConsumer>
</NativeProviders>
</TranslationsProvider>
</AccountProvider>
</ErrorProvider>
<RnTheme>
<ErrorProvider>
<AccountProvider>
<TranslationsProvider>
<NativeProviders>
<ErrorConsumer scope="root">{children}</ErrorConsumer>
</NativeProviders>
</TranslationsProvider>
</AccountProvider>
</ErrorProvider>
</RnTheme>
</ThemeProvider>
</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 { MMKV, useMMKVString } from "react-native-mmkv";
import type { z, ZodType } from "zod/v4";
import type { ZodType, z } from "zod/v4";
import { getServerData } from "~/utils";
export const storage = new MMKV();
@ -20,7 +20,9 @@ export const setCookie = (key: string, val?: unknown) => {
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";
const expires = value
? `expires=${d.toUTCString()}`
: "expires=Thu, 01 Jan 1970 00:00:01 GMT";
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 Breakpoint, HR, useBreakpointMap } from "~/primitives";
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 = {
numColumns: Breakpoint<number>;

View File

@ -21,18 +21,22 @@ const cleanSlash = (str: string | null, keepFirst = false) => {
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";
url: string;
body?: object;
formData?: FormData;
plainText?: boolean;
authToken: string | null;
parser?: Parser;
parser: Parser | null;
signal?: AbortSignal;
}): Promise<z.infer<Parser>> => {
if (Platform.OS === "web" && typeof window === "undefined" && context.url.startsWith("/api"))
context.url = `${ssrApiUrl}/${context.url.substring(4)}`;
if (
Platform.OS === "web" &&
typeof window === "undefined" &&
context.url.startsWith("/")
)
context.url = `${ssrApiUrl}${context.url}`;
let resp: Response;
try {
resp = await fetch(context.url, {
@ -44,7 +48,9 @@ const queryFn = async <Parser extends z.ZodTypeAny>(context: {
? context.formData
: undefined,
headers: {
...(context.authToken ? { Authorization: `Bearer ${context.authToken}` } : {}),
...(context.authToken
? { Authorization: `Bearer ${context.authToken}` }
: {}),
...("body" in context ? { "Content-Type": "application/json" } : {}),
},
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")
throw { message: "Aborted", status: "aborted" } as KyooError;
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) {
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.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;
}
@ -80,12 +93,22 @@ const queryFn = async <Parser extends z.ZodTypeAny>(context: {
data = await resp.json();
} catch (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;
const parsed = await context.parser.safeParseAsync(data);
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 {
status: "parse",
message:
@ -109,9 +132,11 @@ export const createQueryClient = () =>
});
export type QueryIdentifier<T = unknown> = {
parser: z.ZodType<T>;
parser: z.ZodType<T> | null;
path: (string | undefined)[];
params?: { [query: string]: boolean | number | string | string[] | undefined };
params?: {
[query: string]: boolean | number | string | string[] | undefined;
};
infinite?: boolean;
placeholderData?: T | (() => T);
@ -124,7 +149,9 @@ export type QueryIdentifier<T = unknown> = {
const toQueryKey = (query: {
apiUrl: string;
path: (string | undefined)[];
params?: { [query: string]: boolean | number | string | string[] | undefined };
params?: {
[query: string]: boolean | number | string | string[] | undefined;
};
}) => {
return [
cleanSlash(query.apiUrl, true),
@ -172,7 +199,7 @@ export const useInfiniteFetch = <Data,>(query: QueryIdentifier<Data>) => {
queryFn: (ctx) =>
queryFn({
url: (ctx.pageParam as string) ?? keyToUrl(key),
parser: Paged(query.parser),
parser: query.parser ? Paged(query.parser) : null,
signal: ctx.signal,
authToken: authToken ?? null,
...query.options,
@ -207,7 +234,7 @@ export const prefetch = async (...queries: QueryIdentifier[]) => {
queryFn: (ctx) =>
queryFn({
url: keyToUrl(key),
parser: Paged(query.parser),
parser: query.parser ? Paged(query.parser) : null,
signal: ctx.signal,
authToken: authToken ?? null,
...query.options,
@ -235,7 +262,9 @@ export const prefetch = async (...queries: QueryIdentifier[]) => {
type MutationParams = {
method?: "POST" | "PUT" | "DELETE";
path?: string[];
params?: { [query: string]: boolean | number | string | string[] | undefined };
params?: {
[query: string]: boolean | number | string | string[] | undefined;
};
body?: object;
};
@ -261,6 +290,7 @@ export const useMutation = <T = void>({
url: keyToUrl(toQueryKey({ apiUrl, path, params })),
body,
authToken,
parser: null,
});
},
onSuccess: invalidate

View File

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

View File

@ -5,8 +5,8 @@ import { OfflineView } from "./offline";
// import { SetupPage } from "./setup";
import { Unauthorized } from "./unauthorized";
export * from "./error";
export * from "./empty";
export * from "./error";
export type ErrorHandler = {
view: FC<{ error: KyooError; retry: () => void }>;
@ -16,6 +16,6 @@ export type ErrorHandler = {
export const errorHandlers: Record<string, ErrorHandler> = {
unauthorized: { view: Unauthorized, forbid: "app" },
// setup: { view: SetupPage, forbid: "setup" },
connection: { view: ConnectionError },
connection: { view: ConnectionError, forbid: "login" },
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 { ImageBackground, type ImageProps, Platform, ScrollView, View } from "react-native";
import Svg, { type SvgProps, Path } from "react-native-svg";
import { type Stylable, min, px, useYoshiki, vh } from "yoshiki/native";
import { ImageBackground, ScrollView, View } from "react-native";
import Svg, { Path, type SvgProps } from "react-native-svg";
import { min, px, type Stylable, useYoshiki, vh } from "yoshiki/native";
import { ts } from "~/primitives";
const SvgBlob = (props: SvgProps) => {
const { css, theme } = useYoshiki();
@ -27,18 +26,9 @@ export const FormPage = ({
}: { children: ReactNode; apiUrl?: string } & Stylable) => {
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 (
<ImageBackground
source={{ uri: src }}
{...nativeProps}
source={{ uri: `${apiUrl ?? ""}/api/shows/random/thumbnail` }}
{...css({
flexDirection: "row",
flexGrow: 1,

View File

@ -1,4 +1,3 @@
export { LoginPage } from "./login";
export { RegisterPage } from "./register";
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 { queryFn } from "~/query";
@ -8,70 +9,83 @@ type Result<A, B> =
export const login = async (
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>> => {
if (!apiUrl || apiUrl.length === 0) apiUrl = getCurrentApiUrl()!;
apiUrl ??= "";
try {
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
const token = await queryFn(
{
path: ["auth", action],
method: "POST",
body,
authenticated: false,
apiUrl,
signal: controller.signal,
},
TokenP,
);
const { token } = await queryFn({
method: "POST",
url: `${apiUrl}/auth/${action === "login" ? "sessions" : "users"}`,
body,
authToken: null,
signal: controller.signal,
parser: z.object({ token: z.string() }),
});
const user = await queryFn({
method: "GET",
path: ["auth", "me"],
apiUrl,
authToken: token.access_token,
parser: UserP,
url: `${apiUrl}/auth/users/me`,
authToken: token,
parser: User,
});
const account: Account = { ...user, apiUrl: apiUrl, token, selected: true };
const account: Account = { ...user, apiUrl, token, selected: true };
addAccount(account);
return { ok: true, value: account };
} catch (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) => {
if (!apiUrl || apiUrl.length === 0) apiUrl = getCurrentApiUrl()!;
try {
const token = await queryFn(
{
path: ["auth", "callback", provider, `?code=${code}`],
method: "POST",
authenticated: false,
apiUrl,
},
TokenP,
);
const user = await queryFn(
{ path: ["auth", "me"], method: "GET", apiUrl },
UserP,
`Bearer ${token.access_token}`,
);
const account: Account = { ...user, apiUrl: apiUrl, token, selected: true };
addAccount(account);
return { ok: true, value: account };
} catch (e) {
console.error("oidcLogin", e);
return { ok: false, error: (e as KyooErrors).errors[0] };
}
};
// export const oidcLogin = async (
// provider: string,
// code: string,
// apiUrl?: string,
// ) => {
// if (!apiUrl || apiUrl.length === 0) apiUrl = getCurrentApiUrl()!;
// try {
// const token = await queryFn(
// {
// path: ["auth", "callback", provider, `?code=${code}`],
// method: "POST",
// authenticated: false,
// apiUrl,
// },
// TokenP,
// );
// const user = await queryFn(
// { path: ["auth", "me"], method: "GET", apiUrl },
// UserP,
// `Bearer ${token.access_token}`,
// );
// const account: Account = { ...user, apiUrl: apiUrl, token, selected: true };
// 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 = () => {
removeAccounts((x) => x.selected);
};
export const deleteAccount = async () => {
await queryFn({ path: ["auth", "me"], method: "DELETE" });
// await queryFn({
// method: "DELETE",
// url: "auth/users/me",
// parser: null,
// });
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 { Platform } from "react-native";
import { percent, px, useYoshiki } from "yoshiki/native";
import { type QueryPage, login } from "~/models";
import { A, Button, H1, Input, P, ts } from "~/primitives";
import { useQueryState } from "~/utils";
import { FormPage } from "./form";
import { OidcLogin } from "./oidc";
import { login } from "./logic";
import { PasswordInput } from "./password-input";
import { ServerUrlPage } from "./server-url";
export const LoginPage: QueryPage<{ apiUrl?: string; error?: string }> = ({
apiUrl,
error: initialError,
}) => {
const { data } = useFetch({
path: ["info"],
parser: ServerInfoP,
});
export const LoginPage = () => {
const [apiUrl] = useQueryState("apiUrl", null);
const [username, setUsername] = 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 { css } = useYoshiki();
const router = useRouter();
useEffect(() => {
if (!apiUrl && Platform.OS !== "web")
router.replace("/server-url", undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false },
});
}, [apiUrl, router]);
if (Platform.OS !== "web" && !apiUrl) return <ServerUrlPage />;
return (
<FormPage apiUrl={apiUrl}>
<FormPage apiUrl={apiUrl!}>
<H1>{t("login.login")}</H1>
<OidcLogin apiUrl={apiUrl} hideOr={!data?.passwordLoginEnabled} />
{data?.passwordLoginEnabled && (
<>
<P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P>
<Input
autoComplete="username"
variant="big"
onChangeText={(value) => setUsername(value)}
autoCapitalize="none"
/>
<P {...css({ paddingLeft: ts(1) })}>{t("login.password")}</P>
<PasswordInput
autoComplete="password"
variant="big"
onChangeText={(value) => setPassword(value)}
/>
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>}
<Button
text={t("login.login")}
onPress={async () => {
const { error } = await login("login", {
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),
})}
/>
</>
)}
{/* <OidcLogin apiUrl={apiUrl} hideOr={!data?.passwordLoginEnabled} /> */}
{/* {data?.passwordLoginEnabled && ( */}
{/* <> */}
<P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.username")}</P>
<Input
autoComplete="username"
variant="big"
onChangeText={(value) => setUsername(value)}
autoCapitalize="none"
/>
<P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.password")}</P>
<PasswordInput
autoComplete="password"
variant="big"
onChangeText={(value) => setPassword(value)}
/>
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>}
<Button
text={t("login.login")}
onPress={async () => {
const { error } = await login("login", {
login: 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>
<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>
</P>
</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 Visibility from "@material-symbols/svg-400/rounded/visibility-fill.svg";
import { type ComponentProps, useState } from "react";
import { px, useYoshiki } from "yoshiki/native";
import { IconButton, Input } from "~/primitives";
export const PasswordInput = (props: ComponentProps<typeof Input>) => {
const { css } = useYoshiki();

View File

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

View File

@ -1,28 +1,23 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ImageBackground, Platform, View } from "react-native";
import { type Theme, percent, useYoshiki } from "yoshiki/native";
import { type ServerInfo, ServerInfoP } from "~/models";
import { Button, H1, HR, Input, Link, P, ts } from "~/primitives";
import { DefaultLayout } from "../../../packages/ui/src/layout";
import { Platform, View } from "react-native";
import { type Theme, useYoshiki } from "yoshiki/native";
import { Button, H1, Input, Link, P, ts } from "~/primitives";
import { type QueryIdentifier, useFetch } from "~/query";
export const cleanApiUrl = (apiUrl: string) => {
if (Platform.OS === "web") return undefined;
if (!/https?:\/\//.test(apiUrl)) apiUrl = `http://${apiUrl}`;
apiUrl = apiUrl.replace(/\/$/, "");
return `${apiUrl}/api`;
return apiUrl.replace(/\/$/, "");
};
const query: QueryIdentifier<ServerInfo> = {
path: ["info"],
parser: ServerInfoP,
};
export const ServerUrlPage: QueryPage = () => {
export const ServerUrlPage = () => {
const [_apiUrl, setApiUrl] = useState("");
const apiUrl = cleanApiUrl(_apiUrl);
const { data, error } = useFetch({ ...query, options: { apiUrl, authenticated: false } });
const router = useRouter();
const { data, error } = useFetch({
...ServerUrlPage.query,
options: { apiUrl, authToken: null },
});
const { t } = useTranslation();
const { css } = useYoshiki();
@ -36,48 +31,56 @@ export const ServerUrlPage: QueryPage = () => {
>
<H1>{t("login.server")}</H1>
<View {...css({ justifyContent: "center" })}>
<Input variant="big" onChangeText={setApiUrl} autoCorrect={false} autoCapitalize="none" />
<Input
variant="big"
onChangeText={setApiUrl}
autoCorrect={false}
autoCapitalize="none"
/>
{!data && (
<P {...css({ color: (theme: Theme) => theme.colors.red, alignSelf: "center" })}>
{error?.errors[0] ?? t("misc.loading")}
<P
{...css({
color: (theme: Theme) => theme.colors.red,
alignSelf: "center",
})}
>
{error?.message ?? t("misc.loading")}
</P>
)}
</View>
<View {...css({ marginTop: ts(5) })}>
<View {...css({ flexDirection: "row", width: percent(100), alignItems: "center" })}>
{data &&
Object.values(data.oidc).map((x) => (
<Link
key={x.displayName}
href={{ pathname: x.link, query: { apiUrl } }}
{...css({ justifyContent: "center" })}
>
{x.logoUrl != null ? (
<ImageBackground
source={{ uri: x.logoUrl }}
{...css({ width: ts(3), height: ts(3), margin: ts(1) })}
/>
) : (
t("login.via", { provider: x.displayName })
)}
</Link>
))}
</View>
<HR />
{/* <View {...css({ flexDirection: "row", width: percent(100), alignItems: "center" })}> */}
{/* {data && */}
{/* Object.values(data.oidc).map((x) => ( */}
{/* <Link */}
{/* key={x.displayName} */}
{/* href={{ pathname: x.link, query: { apiUrl } }} */}
{/* {...css({ justifyContent: "center" })} */}
{/* > */}
{/* {x.logoUrl != null ? ( */}
{/* <ImageBackground */}
{/* source={{ uri: x.logoUrl }} */}
{/* {...css({ width: ts(3), height: ts(3), margin: ts(1) })} */}
{/* /> */}
{/* ) : ( */}
{/* t("login.via", { provider: x.displayName }) */}
{/* )} */}
{/* </Link> */}
{/* ))} */}
{/* </View> */}
{/* <HR /> */}
<View {...css({ flexDirection: "row", gap: ts(2) })}>
<Button
text={t("login.login")}
onPress={() => {
router.push(`/login?apiUrl=${apiUrl}`);
}}
as={Link}
href={`/login?apiUrl=${apiUrl}`}
disabled={data == null}
{...css({ flexGrow: 1, flexShrink: 1 })}
/>
<Button
text={t("login.register")}
onPress={() => {
router.push(`/register?apiUrl=${apiUrl}`);
}}
as={Link}
href={`/register?apiUrl=${apiUrl}`}
disabled={data == null}
{...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 Search from "@material-symbols/svg-400/rounded/search-fill.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 { Platform, type TextInput, type TextInputProps, View, type ViewProps } from "react-native";
import { type Stylable, percent, useYoshiki } from "yoshiki/native";
import {
Platform,
type TextInput,
type TextInputProps,
View,
type ViewProps,
} from "react-native";
import { type Theme, useYoshiki } from "yoshiki/native";
import {
A,
Avatar,
HR,
Header,
IconButton,
Input,
Link,
@ -21,29 +26,30 @@ import {
tooltip,
ts,
} from "~/primitives";
import { useAccount, useAccounts, useHasPermission } from "~/providers/account-context";
import { useAccount, useAccounts } from "~/providers/account-context";
import { logout } from "~/ui/login/logic";
import { useQueryState } from "~/utils";
// import { AdminPage } from "../../../packages/ui/src/admin/packages/ui/src/admin";
import { KyooLongLogo } from "./icon";
export const NavbarTitle = (props: Stylable & { onLayout?: ViewProps["onLayout"] }) => {
export const NavbarTitle = (props: { onLayout?: ViewProps["onLayout"] }) => {
const { t } = useTranslation();
const { css } = useYoshiki();
return (
<A
href="/"
aria-label={t("navbar.home")}
{...tooltip(t("navbar.home"))}
{...css({ fontSize: 0 }, props)}
{...props}
>
<KyooLongLogo />
</A>
);
};
const SearchBar = ({ ref, ...props }: TextInputProps & { ref?: Ref<TextInput> }) => {
const SearchBar = ({
ref,
...props
}: TextInputProps & { ref?: Ref<TextInput> }) => {
const { theme } = useYoshiki();
const { t } = useTranslation();
const [query, setQuery] = useQueryState("q", "");
@ -55,7 +61,11 @@ const SearchBar = ({ ref, ...props }: TextInputProps & { ref?: Ref<TextInput> })
onChangeText={setQuery}
placeholder={t("navbar.search")}
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"))}
{...props}
/>
@ -89,7 +99,11 @@ export const NavbarProfile = () => {
{accounts?.map((x) => (
<Menu.Item
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} />}
selected={x.selected}
onSelect={() => x.select()}
@ -100,12 +114,24 @@ export const NavbarProfile = () => {
{!account ? (
<>
<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 label={t("login.logout")} icon={Logout} onSelect={logout} />
<Menu.Item
label={t("login.add-account")}
icon={Login}
href="/login"
/>
<Menu.Item
label={t("login.logout")}
icon={Logout}
onSelect={logout}
/>
</>
)}
</Menu>
@ -117,7 +143,9 @@ export const NavbarRight = () => {
const isAdmin = false; //useHasPermission(AdminPage.requiredPermissions);
return (
<View {...css({ flexDirection: "row", alignItems: "center", flexShrink: 1 })}>
<View
{...css({ flexDirection: "row", alignItems: "center", flexShrink: 1 })}
>
{Platform.OS === "web" ? (
<SearchBar />
) : (
@ -143,72 +171,78 @@ export const NavbarRight = () => {
);
};
export const Navbar = ({
left,
right,
background,
...props
}: {
left?: ReactElement | null;
right?: ReactElement | null;
background?: ReactElement;
} & Stylable) => {
const { css } = useYoshiki();
const { t } = useTranslation();
return (
<Header
{...css(
{
backgroundColor: (theme) => theme.accent,
paddingX: ts(2),
height: { xs: 48, sm: 64 },
flexDirection: "row",
justifyContent: { xs: "space-between", sm: "flex-start" },
alignItems: "center",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 4.65,
elevation: 8,
zIndex: 1,
},
props,
)}
>
{background}
<View {...css({ flexDirection: "row", alignItems: "center", height: percent(100) })}>
{left !== undefined ? (
left
) : (
<>
<NavbarTitle {...css({ marginX: ts(2) })} />
<A
href="/browse"
{...css({
textTransform: "uppercase",
fontWeight: "bold",
color: (theme) => theme.contrast,
})}
>
{t("navbar.browse")}
</A>
</>
)}
</View>
<View
{...css({
flexGrow: 1,
flexShrink: 1,
flexDirection: "row",
display: { xs: "none", sm: "flex" },
marginX: ts(2),
})}
/>
{right !== undefined ? right : <NavbarRight />}
</Header>
);
};
// export const Navbar = ({
// left,
// right,
// background,
// ...props
// }: {
// left?: ReactElement | null;
// right?: ReactElement | null;
// background?: ReactElement;
// } & Stylable) => {
// const { css } = useYoshiki();
// const { t } = useTranslation();
//
// return (
// <Header
// {...css(
// {
// backgroundColor: (theme) => theme.accent,
// paddingX: ts(2),
// height: { xs: 48, sm: 64 },
// flexDirection: "row",
// justifyContent: { xs: "space-between", sm: "flex-start" },
// alignItems: "center",
// shadowColor: "#000",
// shadowOffset: {
// width: 0,
// height: 4,
// },
// shadowOpacity: 0.3,
// shadowRadius: 4.65,
// elevation: 8,
// zIndex: 1,
// },
// props,
// )}
// >
// {background}
// <View
// {...css({
// flexDirection: "row",
// alignItems: "center",
// height: percent(100),
// })}
// >
// {left !== undefined ? (
// left
// ) : (
// <>
// <NavbarTitle {...css({ marginX: ts(2) })} />
// <A
// href="/browse"
// {...css({
// textTransform: "uppercase",
// fontWeight: "bold",
// color: (theme) => theme.contrast,
// })}
// >
// {t("navbar.browse")}
// </A>
// </>
// )}
// </View>
// <View
// {...css({
// flexGrow: 1,
// flexShrink: 1,
// flexDirection: "row",
// display: { xs: "none", sm: "flex" },
// marginX: ts(2),
// })}
// />
// {right !== undefined ? right : <NavbarRight />}
// </Header>
// );
// };