mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-09 03:04:20 -04:00
Add back login/register (& server url picker on mobile)
This commit is contained in:
parent
552926d2cb
commit
dfe0d52b1e
@ -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>
|
||||
|
@ -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"],
|
||||
},
|
||||
});
|
||||
|
@ -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>
|
||||
|
27
front/app/(public)/_layout.tsx
Normal file
27
front/app/(public)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
import { LoginPage } from "~/ui/login";
|
||||
|
||||
export default LoginPage;
|
3
front/app/(public)/register.tsx
Normal file
3
front/app/(public)/register.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import { RegisterPage } from "~/ui/login";
|
||||
|
||||
export default RegisterPage;
|
@ -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();
|
||||
// });
|
@ -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();
|
||||
|
11
front/src/models/account.ts
Normal file
11
front/src/models/account.ts
Normal 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>;
|
@ -9,4 +9,6 @@ export * from "./entry";
|
||||
export * from "./studio";
|
||||
export * from "./video";
|
||||
|
||||
export * from "./user";
|
||||
|
||||
export * from "./utils/images";
|
||||
|
@ -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>;
|
@ -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
58
front/src/models/user.ts
Normal 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>;
|
@ -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>;
|
||||
|
@ -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}
|
||||
|
@ -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));
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
17
front/src/providers/permissions.tsx
Normal file
17
front/src/providers/permissions.tsx
Normal 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));
|
||||
// };
|
@ -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`;
|
||||
};
|
||||
|
||||
|
@ -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>;
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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 },
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -1,4 +1,3 @@
|
||||
export { LoginPage } from "./login";
|
||||
export { RegisterPage } from "./register";
|
||||
export { ServerUrlPage } from "./server-url";
|
||||
export { OidcCallbackPage } from "./oidc";
|
||||
|
@ -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();
|
||||
};
|
||||
|
@ -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">
|
||||
Don’t have an account? <A href={{ pathname: "/register", query: { apiUrl } }}>Register</A>
|
||||
.
|
||||
Don’t have an account?{" "}
|
||||
<A href={`/register?apiUrl=${apiUrl}`}>Register</A>.
|
||||
</Trans>
|
||||
</P>
|
||||
</FormPage>
|
||||
);
|
||||
};
|
||||
|
||||
LoginPage.getFetchUrls = () => [OidcLogin.query()];
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
// );
|
||||
// };
|
||||
|
Loading…
x
Reference in New Issue
Block a user