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"
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
tabBarLabel: t("navbar.home"),
|
tabBarLabel: t("navbar.home"),
|
||||||
tabBarIcon: ({ color, size }) => <Icon icon={Home} color={color} size={size} />,
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Icon icon={Home} color={color} size={size} />
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="browse"
|
name="browse"
|
||||||
options={{
|
options={{
|
||||||
tabBarLabel: t("navbar.browse"),
|
tabBarLabel: t("navbar.browse"),
|
||||||
tabBarIcon: ({ color, size }) => <Icon icon={Browse} color={color} size={size} />,
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Icon icon={Browse} color={color} size={size} />
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="downloads"
|
name="downloads"
|
||||||
options={{
|
options={{
|
||||||
tabBarLabel: t("navbar.download"),
|
tabBarLabel: t("navbar.download"),
|
||||||
tabBarIcon: ({ color, size }) => <Icon icon={Downloading} color={color} size={size} />,
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Icon icon={Downloading} color={color} size={size} />
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useYoshiki } from "yoshiki/native";
|
import { useYoshiki } from "yoshiki/native";
|
||||||
import { type LibraryItem, LibraryItemP } from "~/models";
|
import { Show } from "~/models";
|
||||||
import { P } from "~/primitives";
|
import { P } from "~/primitives";
|
||||||
import { Fetch, type QueryIdentifier, prefetch } from "~/query";
|
import { Fetch, prefetch, type QueryIdentifier } from "~/query";
|
||||||
|
|
||||||
export async function loader() {
|
export async function loader() {
|
||||||
await prefetch(Header.query());
|
await prefetch(Header.query());
|
||||||
@ -18,17 +18,17 @@ export default function Header() {
|
|||||||
<P>{t("home.recommended")}</P>
|
<P>{t("home.recommended")}</P>
|
||||||
<Fetch
|
<Fetch
|
||||||
query={Header.query()}
|
query={Header.query()}
|
||||||
Render={({ name }) => <P {...css({ bg: "red" })}>{name}</P>}
|
Render={({ name }) => <P {...(css({ bg: "red" }) as any)}>{name}</P>}
|
||||||
Loader={() => <P>Loading</P>}
|
Loader={() => <P>Loading</P>}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Header.query = (): QueryIdentifier<LibraryItem> => ({
|
Header.query = (): QueryIdentifier<Show> => ({
|
||||||
parser: LibraryItemP,
|
parser: Show,
|
||||||
path: ["items", "random"],
|
path: ["shows", "random"],
|
||||||
params: {
|
params: {
|
||||||
fields: ["firstEpisode"],
|
fields: ["firstEntry"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -12,21 +12,15 @@ export default function Layout() {
|
|||||||
<ErrorConsumer scope="app">
|
<ErrorConsumer scope="app">
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
// navigationBarColor: "transparent",
|
|
||||||
// navigationBarTranslucent: true,
|
|
||||||
// statusBarTranslucent: true,
|
|
||||||
// statusBarBackgroundColor: theme.accent,
|
|
||||||
headerTitle: () => <NavbarTitle />,
|
headerTitle: () => <NavbarTitle />,
|
||||||
headerRight: () => <NavbarRight />,
|
headerRight: () => <NavbarRight />,
|
||||||
contentStyle: {
|
contentStyle: {
|
||||||
backgroundColor: theme.background,
|
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
},
|
},
|
||||||
headerStyle: {
|
headerStyle: {
|
||||||
backgroundColor: theme.accent,
|
backgroundColor: theme.accent,
|
||||||
},
|
},
|
||||||
headerTintColor: theme.colors.white,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ErrorConsumer>
|
</ErrorConsumer>
|
||||||
|
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 { ServerInfoP, type User, UserP } from "./resources";
|
||||||
import { zdate } from "./utils";
|
import { zdate } from "./utils";
|
||||||
|
|
||||||
const defaultApiUrl = Platform.OS === "web" ? "/api" : null;
|
|
||||||
const currentApiUrl = atom<string | null>(defaultApiUrl);
|
const currentApiUrl = atom<string | null>(defaultApiUrl);
|
||||||
export const getCurrentApiUrl = () => {
|
export const getCurrentApiUrl = () => {
|
||||||
const store = getDefaultStore();
|
const store = getDefaultStore();
|
||||||
|
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 "./studio";
|
||||||
export * from "./video";
|
export * from "./video";
|
||||||
|
|
||||||
|
export * from "./user";
|
||||||
|
|
||||||
export * from "./utils/images";
|
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) => ({
|
.transform((x) => ({
|
||||||
...x,
|
...x,
|
||||||
low: `/images/${x.id}?quality=low`,
|
low: `/api/images/${x.id}?quality=low`,
|
||||||
medium: `/images/${x.id}?quality=medium`,
|
medium: `/api/images/${x.id}?quality=medium`,
|
||||||
high: `/images/${x.id}?quality=high`,
|
high: `/api/images/${x.id}?quality=high`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export type KImage = z.infer<typeof KImage>;
|
export type KImage = z.infer<typeof KImage>;
|
||||||
|
@ -3,21 +3,26 @@ import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
|||||||
import {
|
import {
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
type ComponentType,
|
type ComponentType,
|
||||||
|
forwardRef,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
forwardRef,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { PressableProps } from "react-native";
|
import type { PressableProps } from "react-native";
|
||||||
import type { SvgProps } from "react-native-svg";
|
import type { SvgProps } from "react-native-svg";
|
||||||
import { useYoshiki as useNativeYoshiki } from "yoshiki/native";
|
import { useYoshiki as useNativeYoshiki } from "yoshiki/native";
|
||||||
import { useYoshiki } from "yoshiki/web";
|
import { useYoshiki } from "yoshiki/web";
|
||||||
import { ContrastArea, SwitchVariant } from "~/primitives";
|
|
||||||
import { Icon } from "./icons";
|
import { Icon } from "./icons";
|
||||||
|
import { Link } from "./links";
|
||||||
import { P } from "./text";
|
import { P } from "./text";
|
||||||
|
import { ContrastArea, SwitchVariant } from "./theme";
|
||||||
import { focusReset, ts } from "./utils";
|
import { focusReset, ts } from "./utils";
|
||||||
|
|
||||||
type YoshikiFunc<T> = (props: ReturnType<typeof useYoshiki>) => T;
|
type YoshikiFunc<T> = (props: ReturnType<typeof useYoshiki>) => T;
|
||||||
export const YoshikiProvider = ({ children }: { children: YoshikiFunc<ReactNode> }) => {
|
export const YoshikiProvider = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: YoshikiFunc<ReactNode>;
|
||||||
|
}) => {
|
||||||
const yoshiki = useYoshiki();
|
const yoshiki = useYoshiki();
|
||||||
return <>{children(yoshiki)}</>;
|
return <>{children(yoshiki)}</>;
|
||||||
};
|
};
|
||||||
@ -26,7 +31,12 @@ export const InternalTriger = forwardRef<unknown, any>(function _Triger(
|
|||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<Component ref={ref} {...ComponentProps} {...props} onClickCapture={props.onPointerDown} />
|
<Component
|
||||||
|
ref={ref}
|
||||||
|
{...ComponentProps}
|
||||||
|
{...props}
|
||||||
|
onClickCapture={props.onPointerDown}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -74,7 +84,8 @@ const Menu = <AsProps extends { onPress: PressableProps["onPress"] }>({
|
|||||||
boxShadow:
|
boxShadow:
|
||||||
"0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)",
|
"0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)",
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
maxHeight: "calc(var(--radix-dropdown-menu-content-available-height) * 0.8)",
|
maxHeight:
|
||||||
|
"calc(var(--radix-dropdown-menu-content-available-height) * 0.8)",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@ -89,25 +100,25 @@ const Menu = <AsProps extends { onPress: PressableProps["onPress"] }>({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Item = forwardRef<
|
const Item = ({
|
||||||
HTMLDivElement,
|
children,
|
||||||
ComponentProps<typeof DropdownMenu.Item> & { href?: string }
|
href,
|
||||||
>(function _Item({ children, href, onSelect, ...props }, ref) {
|
onSelect,
|
||||||
|
...props
|
||||||
|
}: ComponentProps<typeof DropdownMenu.Item> & { href?: string }) => {
|
||||||
if (href) {
|
if (href) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.Item ref={ref} onSelect={onSelect} {...props} asChild>
|
<DropdownMenu.Item onSelect={onSelect} {...props} asChild>
|
||||||
<Link href={href} style={{ textDecoration: "none" }}>
|
<Link href={href}>{children}</Link>
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.Item ref={ref} onSelect={onSelect} {...props}>
|
<DropdownMenu.Item onSelect={onSelect} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
const MenuItem = forwardRef<
|
const MenuItem = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@ -117,8 +128,14 @@ const MenuItem = forwardRef<
|
|||||||
left?: ReactElement;
|
left?: ReactElement;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
} & ({ onSelect: () => void; href?: undefined } | { href: string; onSelect?: undefined })
|
} & (
|
||||||
>(function MenuItem({ label, icon, left, selected, onSelect, href, disabled, ...props }, ref) {
|
| { onSelect: () => void; href?: undefined }
|
||||||
|
| { href: string; onSelect?: undefined }
|
||||||
|
)
|
||||||
|
>(function MenuItem(
|
||||||
|
{ label, icon, left, selected, onSelect, href, disabled, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
const { css: nCss } = useNativeYoshiki();
|
const { css: nCss } = useNativeYoshiki();
|
||||||
const { css, theme } = useYoshiki();
|
const { css, theme } = useYoshiki();
|
||||||
|
|
||||||
@ -133,17 +150,17 @@ const MenuItem = forwardRef<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style jsx global>{`
|
{/* <style jsx global>{` */}
|
||||||
[data-highlighted] {
|
{/* [data-highlighted] { */}
|
||||||
background: ${theme.variant.accent};
|
{/* background: ${theme.variant.accent}; */}
|
||||||
svg {
|
{/* svg { */}
|
||||||
fill: ${theme.alternate.contrast};
|
{/* fill: ${theme.alternate.contrast}; */}
|
||||||
}
|
{/* } */}
|
||||||
div {
|
{/* div { */}
|
||||||
color: ${theme.alternate.contrast};
|
{/* color: ${theme.alternate.contrast}; */}
|
||||||
}
|
{/* } */}
|
||||||
}
|
{/* } */}
|
||||||
`}</style>
|
{/* `}</style> */}
|
||||||
<Item
|
<Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
@ -164,7 +181,10 @@ const MenuItem = forwardRef<
|
|||||||
{!left && icn && icn}
|
{!left && icn && icn}
|
||||||
<P
|
<P
|
||||||
{...nCss([
|
{...nCss([
|
||||||
{ paddingLeft: 8 * 2 + +!(icon || selected || left) * 24, flexGrow: 1 },
|
{
|
||||||
|
paddingLeft: 8 * 2 + +!(icon || selected || left) * 24,
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
disabled && {
|
disabled && {
|
||||||
color: theme.overlay0,
|
color: theme.overlay0,
|
||||||
},
|
},
|
||||||
@ -197,7 +217,11 @@ const Sub = <AsProps,>({
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu.Sub>
|
<DropdownMenu.Sub>
|
||||||
<DropdownMenu.SubTrigger asChild disabled={disabled}>
|
<DropdownMenu.SubTrigger asChild disabled={disabled}>
|
||||||
<MenuItem disabled={disabled} {...props} onSelect={(e?: any) => e.preventDefault()} />
|
<MenuItem
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
onSelect={(e?: any) => e.preventDefault()}
|
||||||
|
/>
|
||||||
</DropdownMenu.SubTrigger>
|
</DropdownMenu.SubTrigger>
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.SubContent
|
<DropdownMenu.SubContent
|
||||||
@ -210,7 +234,8 @@ const Sub = <AsProps,>({
|
|||||||
boxShadow:
|
boxShadow:
|
||||||
"0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)",
|
"0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)",
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
maxHeight: "calc(var(--radix-dropdown-menu-content-available-height) * 0.8)",
|
maxHeight:
|
||||||
|
"calc(var(--radix-dropdown-menu-content-available-height) * 0.8)",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import { type Account, ServerInfoP, type Token } from "~/models";
|
import type { Account } from "~/models";
|
||||||
import { useFetch } from "~/query";
|
|
||||||
|
|
||||||
export const AccountContext = createContext<{
|
export const AccountContext = createContext<{
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
authToken: string | null; //Token | null;
|
authToken: string | null;
|
||||||
selectedAccount: Account | null;
|
selectedAccount: Account | null;
|
||||||
accounts: (Account & { select: () => void; remove: () => void })[];
|
accounts: (Account & { select: () => void; remove: () => void })[];
|
||||||
}>({ apiUrl: "api", authToken: null, selectedAccount: null, accounts: [] });
|
}>({ apiUrl: "", authToken: null, selectedAccount: null, accounts: [] });
|
||||||
|
|
||||||
export const useToken = () => {
|
export const useToken = () => {
|
||||||
const { apiUrl, authToken } = useContext(AccountContext);
|
const { apiUrl, authToken } = useContext(AccountContext);
|
||||||
@ -23,17 +22,3 @@ export const useAccounts = () => {
|
|||||||
const { accounts } = useContext(AccountContext);
|
const { accounts } = useContext(AccountContext);
|
||||||
return accounts;
|
return accounts;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useHasPermission = (perms?: string[]) => {
|
|
||||||
const account = useAccount();
|
|
||||||
const { data } = useFetch({
|
|
||||||
path: ["info"],
|
|
||||||
parser: ServerInfoP,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!perms || !perms[0]) return true;
|
|
||||||
|
|
||||||
const available = account?.permissions ?? data?.guestPermissions;
|
|
||||||
if (!available) return false;
|
|
||||||
return perms.every((perm) => available.includes(perm));
|
|
||||||
};
|
|
||||||
|
@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { type ReactNode, useEffect, useMemo, useRef } from "react";
|
import { type ReactNode, useEffect, useMemo, useRef } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
import { AccountP, UserP } from "~/models";
|
import { Account, User } from "~/models";
|
||||||
import { useFetch } from "~/query";
|
import { useFetch } from "~/query";
|
||||||
import { AccountContext } from "./account-context";
|
import { AccountContext } from "./account-context";
|
||||||
import { removeAccounts, updateAccount } from "./account-store";
|
import { removeAccounts, updateAccount } from "./account-store";
|
||||||
@ -11,12 +11,12 @@ import { useStoreValue } from "./settings";
|
|||||||
|
|
||||||
export const AccountProvider = ({ children }: { children: ReactNode }) => {
|
export const AccountProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [setError, clearError] = useSetError("account");
|
const [setError, clearError] = useSetError("account");
|
||||||
const accounts = useStoreValue("accounts", z.array(AccountP)) ?? [];
|
const accounts = useStoreValue("accounts", z.array(Account)) ?? [];
|
||||||
|
|
||||||
const ret = useMemo(() => {
|
const ret = useMemo(() => {
|
||||||
const acc = accounts.find((x) => x.selected);
|
const acc = accounts.find((x) => x.selected);
|
||||||
return {
|
return {
|
||||||
apiUrl: Platform.OS === "web" ? "/api" : acc?.apiUrl!,
|
apiUrl: acc?.apiUrl ?? "",
|
||||||
authToken: acc?.token ?? null,
|
authToken: acc?.token ?? null,
|
||||||
selectedAccount: acc ?? null,
|
selectedAccount: acc ?? null,
|
||||||
accounts: accounts.map((account) => ({
|
accounts: accounts.map((account) => ({
|
||||||
@ -34,13 +34,13 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
data: user,
|
data: user,
|
||||||
error: userError,
|
error: userError,
|
||||||
} = useFetch({
|
} = useFetch({
|
||||||
path: ["auth", "me"],
|
path: ["auth", "users", "me"],
|
||||||
parser: UserP,
|
parser: User,
|
||||||
placeholderData: ret.selectedAccount,
|
placeholderData: ret.selectedAccount,
|
||||||
enabled: !!ret.selectedAccount,
|
enabled: !!ret.selectedAccount,
|
||||||
options: {
|
options: {
|
||||||
apiUrl: ret.apiUrl,
|
apiUrl: ret.apiUrl,
|
||||||
authToken: ret.authToken?.access_token,
|
authToken: ret.authToken,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Use a ref here because we don't want the effect to trigger when the selected
|
// Use a ref here because we don't want the effect to trigger when the selected
|
||||||
@ -74,5 +74,7 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
queryClient.resetQueries();
|
queryClient.resetQueries();
|
||||||
}, [selectedId, queryClient]);
|
}, [selectedId, queryClient]);
|
||||||
|
|
||||||
return <AccountContext.Provider value={ret}>{children}</AccountContext.Provider>;
|
return (
|
||||||
|
<AccountContext.Provider value={ret}>{children}</AccountContext.Provider>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
import { type Account, AccountP } from "~/models";
|
import { Account } from "~/models";
|
||||||
import { readValue, setCookie, storeValue } from "./settings";
|
import { readValue, setCookie, storeValue } from "./settings";
|
||||||
|
|
||||||
const writeAccounts = (accounts: Account[]) => {
|
const writeAccounts = (accounts: Account[]) => {
|
||||||
@ -10,12 +10,12 @@ const writeAccounts = (accounts: Account[]) => {
|
|||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
setCookie("account", selected);
|
setCookie("account", selected);
|
||||||
// cookie used for images and videos since we can't add Authorization headers in img or video tags.
|
// cookie used for images and videos since we can't add Authorization headers in img or video tags.
|
||||||
setCookie("X-Bearer", selected?.token.access_token);
|
setCookie("X-Bearer", selected?.token);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const addAccount = (account: Account) => {
|
export const addAccount = (account: Account) => {
|
||||||
const accounts = readValue("accounts", z.array(AccountP)) ?? [];
|
const accounts = readValue("accounts", z.array(Account)) ?? [];
|
||||||
|
|
||||||
// Prevent the user from adding the same account twice.
|
// Prevent the user from adding the same account twice.
|
||||||
if (accounts.find((x) => x.id === account.id)) {
|
if (accounts.find((x) => x.id === account.id)) {
|
||||||
@ -30,7 +30,7 @@ export const addAccount = (account: Account) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const removeAccounts = (filter: (acc: Account) => boolean) => {
|
export const removeAccounts = (filter: (acc: Account) => boolean) => {
|
||||||
let accounts = readValue("accounts", z.array(AccountP)) ?? [];
|
let accounts = readValue("accounts", z.array(Account)) ?? [];
|
||||||
accounts = accounts.filter((x) => !filter(x));
|
accounts = accounts.filter((x) => !filter(x));
|
||||||
if (!accounts.find((x) => x.selected) && accounts.length > 0) {
|
if (!accounts.find((x) => x.selected) && accounts.length > 0) {
|
||||||
accounts[0].selected = true;
|
accounts[0].selected = true;
|
||||||
@ -39,7 +39,7 @@ export const removeAccounts = (filter: (acc: Account) => boolean) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const updateAccount = (id: string, account: Account) => {
|
export const updateAccount = (id: string, account: Account) => {
|
||||||
const accounts = readValue("accounts", z.array(AccountP)) ?? [];
|
const accounts = readValue("accounts", z.array(Account)) ?? [];
|
||||||
const idx = accounts.findIndex((x) => x.id === id);
|
const idx = accounts.findIndex((x) => x.id === id);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
|
import {
|
||||||
|
DefaultTheme,
|
||||||
|
ThemeProvider as RNThemeProvider,
|
||||||
|
} from "@react-navigation/native";
|
||||||
import { HydrationBoundary, QueryClientProvider } from "@tanstack/react-query";
|
import { HydrationBoundary, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { type ReactNode, useState } from "react";
|
import { type ReactNode, useState } from "react";
|
||||||
// import { useUserTheme } from "@kyoo/models";
|
import { useColorScheme } from "react-native";
|
||||||
|
import { useTheme } from "yoshiki/native";
|
||||||
import { ThemeSelector } from "~/primitives/theme";
|
import { ThemeSelector } from "~/primitives/theme";
|
||||||
import { createQueryClient } from "~/query";
|
import { createQueryClient } from "~/query";
|
||||||
import { AccountProvider } from "./account-provider";
|
import { AccountProvider } from "./account-provider";
|
||||||
@ -9,32 +14,58 @@ import { ErrorProvider } from "./error-provider";
|
|||||||
import { NativeProviders } from "./native-providers";
|
import { NativeProviders } from "./native-providers";
|
||||||
import { TranslationsProvider } from "./translations.native";
|
import { TranslationsProvider } from "./translations.native";
|
||||||
|
|
||||||
function getServerData(key: string) {}
|
function getServerData(_key: string) {}
|
||||||
|
|
||||||
const QueryProvider = ({ children }: { children: ReactNode }) => {
|
const QueryProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [queryClient] = useState(() => createQueryClient());
|
const [queryClient] = useState(() => createQueryClient());
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<HydrationBoundary state={getServerData("queryState")}>{children}</HydrationBoundary>
|
<HydrationBoundary state={getServerData("queryState")}>
|
||||||
|
{children}
|
||||||
|
</HydrationBoundary>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
||||||
// TODO: change "auto" and use the user's theme cookie
|
// we can't use "auto" here because it breaks the `RnThemeProvider`
|
||||||
const userTheme = "auto"; //useUserTheme("auto");
|
const userTheme = useColorScheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeSelector theme={userTheme} font={{ normal: "inherit" }}>
|
<ThemeSelector theme={userTheme ?? "light"} font={{ normal: "inherit" }}>
|
||||||
{children}
|
{children}
|
||||||
</ThemeSelector>
|
</ThemeSelector>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RnTheme = ({ children }: { children: ReactNode }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RNThemeProvider
|
||||||
|
value={{
|
||||||
|
dark: theme.mode === "dark",
|
||||||
|
colors: {
|
||||||
|
primary: theme.accent,
|
||||||
|
card: theme.variant.background,
|
||||||
|
text: theme.paragraph,
|
||||||
|
border: theme.background,
|
||||||
|
notification: theme.background,
|
||||||
|
background: theme.background,
|
||||||
|
},
|
||||||
|
fonts: DefaultTheme.fonts,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RNThemeProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const Providers = ({ children }: { children: ReactNode }) => {
|
export const Providers = ({ children }: { children: ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<RnTheme>
|
||||||
<ErrorProvider>
|
<ErrorProvider>
|
||||||
<AccountProvider>
|
<AccountProvider>
|
||||||
<TranslationsProvider>
|
<TranslationsProvider>
|
||||||
@ -44,6 +75,7 @@ export const Providers = ({ children }: { children: ReactNode }) => {
|
|||||||
</TranslationsProvider>
|
</TranslationsProvider>
|
||||||
</AccountProvider>
|
</AccountProvider>
|
||||||
</ErrorProvider>
|
</ErrorProvider>
|
||||||
|
</RnTheme>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryProvider>
|
</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 { Platform } from "react-native";
|
||||||
import { MMKV, useMMKVString } from "react-native-mmkv";
|
import { MMKV, useMMKVString } from "react-native-mmkv";
|
||||||
import type { z, ZodType } from "zod/v4";
|
import type { ZodType, z } from "zod/v4";
|
||||||
import { getServerData } from "~/utils";
|
import { getServerData } from "~/utils";
|
||||||
|
|
||||||
export const storage = new MMKV();
|
export const storage = new MMKV();
|
||||||
@ -20,7 +20,9 @@ export const setCookie = (key: string, val?: unknown) => {
|
|||||||
const d = new Date();
|
const d = new Date();
|
||||||
// A year
|
// A year
|
||||||
d.setTime(d.getTime() + 365 * 24 * 60 * 60 * 1000);
|
d.setTime(d.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||||
const expires = value ? `expires=${d.toUTCString()}` : "expires=Thu, 01 Jan 1970 00:00:01 GMT";
|
const expires = value
|
||||||
|
? `expires=${d.toUTCString()}`
|
||||||
|
: "expires=Thu, 01 Jan 1970 00:00:01 GMT";
|
||||||
document.cookie = `${key}=${value};${expires};path=/;samesite=strict`;
|
document.cookie = `${key}=${value};${expires};path=/;samesite=strict`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -2,8 +2,8 @@ import { LegendList } from "@legendapp/list";
|
|||||||
import { type ComponentType, type ReactElement, useRef } from "react";
|
import { type ComponentType, type ReactElement, useRef } from "react";
|
||||||
import { type Breakpoint, HR, useBreakpointMap } from "~/primitives";
|
import { type Breakpoint, HR, useBreakpointMap } from "~/primitives";
|
||||||
import { useSetError } from "~/providers/error-provider";
|
import { useSetError } from "~/providers/error-provider";
|
||||||
import { type QueryIdentifier, useInfiniteFetch } from "~/query";
|
import { ErrorView } from "~/ui/errors";
|
||||||
import { ErrorView } from "../ui/errors";
|
import { type QueryIdentifier, useInfiniteFetch } from "./query";
|
||||||
|
|
||||||
export type Layout = {
|
export type Layout = {
|
||||||
numColumns: Breakpoint<number>;
|
numColumns: Breakpoint<number>;
|
||||||
|
@ -21,18 +21,22 @@ const cleanSlash = (str: string | null, keepFirst = false) => {
|
|||||||
return str.replace(/^\/|\/$/g, "");
|
return str.replace(/^\/|\/$/g, "");
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryFn = async <Parser extends z.ZodTypeAny>(context: {
|
export const queryFn = async <Parser extends z.ZodTypeAny>(context: {
|
||||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
url: string;
|
url: string;
|
||||||
body?: object;
|
body?: object;
|
||||||
formData?: FormData;
|
formData?: FormData;
|
||||||
plainText?: boolean;
|
plainText?: boolean;
|
||||||
authToken: string | null;
|
authToken: string | null;
|
||||||
parser?: Parser;
|
parser: Parser | null;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}): Promise<z.infer<Parser>> => {
|
}): Promise<z.infer<Parser>> => {
|
||||||
if (Platform.OS === "web" && typeof window === "undefined" && context.url.startsWith("/api"))
|
if (
|
||||||
context.url = `${ssrApiUrl}/${context.url.substring(4)}`;
|
Platform.OS === "web" &&
|
||||||
|
typeof window === "undefined" &&
|
||||||
|
context.url.startsWith("/")
|
||||||
|
)
|
||||||
|
context.url = `${ssrApiUrl}${context.url}`;
|
||||||
let resp: Response;
|
let resp: Response;
|
||||||
try {
|
try {
|
||||||
resp = await fetch(context.url, {
|
resp = await fetch(context.url, {
|
||||||
@ -44,7 +48,9 @@ const queryFn = async <Parser extends z.ZodTypeAny>(context: {
|
|||||||
? context.formData
|
? context.formData
|
||||||
: undefined,
|
: undefined,
|
||||||
headers: {
|
headers: {
|
||||||
...(context.authToken ? { Authorization: `Bearer ${context.authToken}` } : {}),
|
...(context.authToken
|
||||||
|
? { Authorization: `Bearer ${context.authToken}` }
|
||||||
|
: {}),
|
||||||
...("body" in context ? { "Content-Type": "application/json" } : {}),
|
...("body" in context ? { "Content-Type": "application/json" } : {}),
|
||||||
},
|
},
|
||||||
signal: context.signal,
|
signal: context.signal,
|
||||||
@ -53,7 +59,10 @@ const queryFn = async <Parser extends z.ZodTypeAny>(context: {
|
|||||||
if (typeof e === "object" && e && "name" in e && e.name === "AbortError")
|
if (typeof e === "object" && e && "name" in e && e.name === "AbortError")
|
||||||
throw { message: "Aborted", status: "aborted" } as KyooError;
|
throw { message: "Aborted", status: "aborted" } as KyooError;
|
||||||
console.log("Fetch error", e, context.url);
|
console.log("Fetch error", e, context.url);
|
||||||
throw { message: "Could not reach Kyoo's server.", status: "aborted" } as KyooError;
|
throw {
|
||||||
|
message: "Could not reach Kyoo's server.",
|
||||||
|
status: "aborted",
|
||||||
|
} as KyooError;
|
||||||
}
|
}
|
||||||
if (resp.status === 404) {
|
if (resp.status === 404) {
|
||||||
throw { message: "Resource not found.", status: 404 } as KyooError;
|
throw { message: "Resource not found.", status: 404 } as KyooError;
|
||||||
@ -67,7 +76,11 @@ const queryFn = async <Parser extends z.ZodTypeAny>(context: {
|
|||||||
data = { message: error } as KyooError;
|
data = { message: error } as KyooError;
|
||||||
}
|
}
|
||||||
data.status = resp.status;
|
data.status = resp.status;
|
||||||
console.log(`Invalid response (${context.method ?? "GET"} ${context.url}):`, data, resp.status);
|
console.log(
|
||||||
|
`Invalid response (${context.method ?? "GET"} ${context.url}):`,
|
||||||
|
data,
|
||||||
|
resp.status,
|
||||||
|
);
|
||||||
throw data as KyooError;
|
throw data as KyooError;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,12 +93,22 @@ const queryFn = async <Parser extends z.ZodTypeAny>(context: {
|
|||||||
data = await resp.json();
|
data = await resp.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Invalid json from kyoo", e);
|
console.error("Invalid json from kyoo", e);
|
||||||
throw { message: "Invalid response from kyoo", status: "json" } as KyooError;
|
throw {
|
||||||
|
message: "Invalid response from kyoo",
|
||||||
|
status: "json",
|
||||||
|
} as KyooError;
|
||||||
}
|
}
|
||||||
if (!context.parser) return data as any;
|
if (!context.parser) return data as any;
|
||||||
const parsed = await context.parser.safeParseAsync(data);
|
const parsed = await context.parser.safeParseAsync(data);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
console.log("Url: ", context.url, " Response: ", resp.status, " Parse error: ", parsed.error);
|
console.log(
|
||||||
|
"Url: ",
|
||||||
|
context.url,
|
||||||
|
" Response: ",
|
||||||
|
resp.status,
|
||||||
|
" Parse error: ",
|
||||||
|
parsed.error,
|
||||||
|
);
|
||||||
throw {
|
throw {
|
||||||
status: "parse",
|
status: "parse",
|
||||||
message:
|
message:
|
||||||
@ -109,9 +132,11 @@ export const createQueryClient = () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type QueryIdentifier<T = unknown> = {
|
export type QueryIdentifier<T = unknown> = {
|
||||||
parser: z.ZodType<T>;
|
parser: z.ZodType<T> | null;
|
||||||
path: (string | undefined)[];
|
path: (string | undefined)[];
|
||||||
params?: { [query: string]: boolean | number | string | string[] | undefined };
|
params?: {
|
||||||
|
[query: string]: boolean | number | string | string[] | undefined;
|
||||||
|
};
|
||||||
infinite?: boolean;
|
infinite?: boolean;
|
||||||
|
|
||||||
placeholderData?: T | (() => T);
|
placeholderData?: T | (() => T);
|
||||||
@ -124,7 +149,9 @@ export type QueryIdentifier<T = unknown> = {
|
|||||||
const toQueryKey = (query: {
|
const toQueryKey = (query: {
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
path: (string | undefined)[];
|
path: (string | undefined)[];
|
||||||
params?: { [query: string]: boolean | number | string | string[] | undefined };
|
params?: {
|
||||||
|
[query: string]: boolean | number | string | string[] | undefined;
|
||||||
|
};
|
||||||
}) => {
|
}) => {
|
||||||
return [
|
return [
|
||||||
cleanSlash(query.apiUrl, true),
|
cleanSlash(query.apiUrl, true),
|
||||||
@ -172,7 +199,7 @@ export const useInfiniteFetch = <Data,>(query: QueryIdentifier<Data>) => {
|
|||||||
queryFn: (ctx) =>
|
queryFn: (ctx) =>
|
||||||
queryFn({
|
queryFn({
|
||||||
url: (ctx.pageParam as string) ?? keyToUrl(key),
|
url: (ctx.pageParam as string) ?? keyToUrl(key),
|
||||||
parser: Paged(query.parser),
|
parser: query.parser ? Paged(query.parser) : null,
|
||||||
signal: ctx.signal,
|
signal: ctx.signal,
|
||||||
authToken: authToken ?? null,
|
authToken: authToken ?? null,
|
||||||
...query.options,
|
...query.options,
|
||||||
@ -207,7 +234,7 @@ export const prefetch = async (...queries: QueryIdentifier[]) => {
|
|||||||
queryFn: (ctx) =>
|
queryFn: (ctx) =>
|
||||||
queryFn({
|
queryFn({
|
||||||
url: keyToUrl(key),
|
url: keyToUrl(key),
|
||||||
parser: Paged(query.parser),
|
parser: query.parser ? Paged(query.parser) : null,
|
||||||
signal: ctx.signal,
|
signal: ctx.signal,
|
||||||
authToken: authToken ?? null,
|
authToken: authToken ?? null,
|
||||||
...query.options,
|
...query.options,
|
||||||
@ -235,7 +262,9 @@ export const prefetch = async (...queries: QueryIdentifier[]) => {
|
|||||||
type MutationParams = {
|
type MutationParams = {
|
||||||
method?: "POST" | "PUT" | "DELETE";
|
method?: "POST" | "PUT" | "DELETE";
|
||||||
path?: string[];
|
path?: string[];
|
||||||
params?: { [query: string]: boolean | number | string | string[] | undefined };
|
params?: {
|
||||||
|
[query: string]: boolean | number | string | string[] | undefined;
|
||||||
|
};
|
||||||
body?: object;
|
body?: object;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -261,6 +290,7 @@ export const useMutation = <T = void>({
|
|||||||
url: keyToUrl(toQueryKey({ apiUrl, path, params })),
|
url: keyToUrl(toQueryKey({ apiUrl, path, params })),
|
||||||
body,
|
body,
|
||||||
authToken,
|
authToken,
|
||||||
|
parser: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: invalidate
|
onSuccess: invalidate
|
||||||
|
@ -45,7 +45,7 @@ BrowsePage.query = (
|
|||||||
): QueryIdentifier<Show> => {
|
): QueryIdentifier<Show> => {
|
||||||
return {
|
return {
|
||||||
parser: Show,
|
parser: Show,
|
||||||
path: ["shows"],
|
path: ["api", "shows"],
|
||||||
infinite: true,
|
infinite: true,
|
||||||
params: {
|
params: {
|
||||||
sort: sortBy ? `${sortOrd === "desc" ? "-" : ""}${sortBy}` : "name",
|
sort: sortBy ? `${sortOrd === "desc" ? "-" : ""}${sortBy}` : "name",
|
||||||
|
@ -5,8 +5,8 @@ import { OfflineView } from "./offline";
|
|||||||
// import { SetupPage } from "./setup";
|
// import { SetupPage } from "./setup";
|
||||||
import { Unauthorized } from "./unauthorized";
|
import { Unauthorized } from "./unauthorized";
|
||||||
|
|
||||||
export * from "./error";
|
|
||||||
export * from "./empty";
|
export * from "./empty";
|
||||||
|
export * from "./error";
|
||||||
|
|
||||||
export type ErrorHandler = {
|
export type ErrorHandler = {
|
||||||
view: FC<{ error: KyooError; retry: () => void }>;
|
view: FC<{ error: KyooError; retry: () => void }>;
|
||||||
@ -16,6 +16,6 @@ export type ErrorHandler = {
|
|||||||
export const errorHandlers: Record<string, ErrorHandler> = {
|
export const errorHandlers: Record<string, ErrorHandler> = {
|
||||||
unauthorized: { view: Unauthorized, forbid: "app" },
|
unauthorized: { view: Unauthorized, forbid: "app" },
|
||||||
// setup: { view: SetupPage, forbid: "setup" },
|
// setup: { view: SetupPage, forbid: "setup" },
|
||||||
connection: { view: ConnectionError },
|
connection: { view: ConnectionError, forbid: "login" },
|
||||||
offline: { view: OfflineView },
|
offline: { view: OfflineView },
|
||||||
};
|
};
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import { imageFn } from "@kyoo/models";
|
|
||||||
import { ts } from "@kyoo/primitives";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { ImageBackground, type ImageProps, Platform, ScrollView, View } from "react-native";
|
import { ImageBackground, ScrollView, View } from "react-native";
|
||||||
import Svg, { type SvgProps, Path } from "react-native-svg";
|
import Svg, { Path, type SvgProps } from "react-native-svg";
|
||||||
import { type Stylable, min, px, useYoshiki, vh } from "yoshiki/native";
|
import { min, px, type Stylable, useYoshiki, vh } from "yoshiki/native";
|
||||||
|
import { ts } from "~/primitives";
|
||||||
|
|
||||||
const SvgBlob = (props: SvgProps) => {
|
const SvgBlob = (props: SvgProps) => {
|
||||||
const { css, theme } = useYoshiki();
|
const { css, theme } = useYoshiki();
|
||||||
@ -27,18 +26,9 @@ export const FormPage = ({
|
|||||||
}: { children: ReactNode; apiUrl?: string } & Stylable) => {
|
}: { children: ReactNode; apiUrl?: string } & Stylable) => {
|
||||||
const { css } = useYoshiki();
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
const src = apiUrl ? `${apiUrl}/items/random/thumbnail` : imageFn("/items/random/thumbnail");
|
|
||||||
const nativeProps = Platform.select<Partial<ImageProps>>({
|
|
||||||
web: {
|
|
||||||
defaultSource: { uri: src },
|
|
||||||
},
|
|
||||||
default: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ImageBackground
|
<ImageBackground
|
||||||
source={{ uri: src }}
|
source={{ uri: `${apiUrl ?? ""}/api/shows/random/thumbnail` }}
|
||||||
{...nativeProps}
|
|
||||||
{...css({
|
{...css({
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
export { LoginPage } from "./login";
|
export { LoginPage } from "./login";
|
||||||
export { RegisterPage } from "./register";
|
export { RegisterPage } from "./register";
|
||||||
export { ServerUrlPage } from "./server-url";
|
export { ServerUrlPage } from "./server-url";
|
||||||
export { OidcCallbackPage } from "./oidc";
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { type Account, type KyooErrors, TokenP, UserP, getCurrentApiUrl } from "~/models";
|
import { z } from "zod/v4";
|
||||||
|
import { type Account, type KyooError, User } from "~/models";
|
||||||
import { addAccount, removeAccounts } from "~/providers/account-store";
|
import { addAccount, removeAccounts } from "~/providers/account-store";
|
||||||
import { queryFn } from "~/query";
|
import { queryFn } from "~/query";
|
||||||
|
|
||||||
@ -8,70 +9,83 @@ type Result<A, B> =
|
|||||||
|
|
||||||
export const login = async (
|
export const login = async (
|
||||||
action: "register" | "login",
|
action: "register" | "login",
|
||||||
{ apiUrl, ...body }: { username: string; password: string; email?: string; apiUrl?: string },
|
{
|
||||||
|
apiUrl,
|
||||||
|
...body
|
||||||
|
}: {
|
||||||
|
login?: string;
|
||||||
|
username?: string;
|
||||||
|
password: string;
|
||||||
|
email?: string;
|
||||||
|
apiUrl: string | null;
|
||||||
|
},
|
||||||
): Promise<Result<Account, string>> => {
|
): Promise<Result<Account, string>> => {
|
||||||
if (!apiUrl || apiUrl.length === 0) apiUrl = getCurrentApiUrl()!;
|
apiUrl ??= "";
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setTimeout(() => controller.abort(), 5_000);
|
setTimeout(() => controller.abort(), 5_000);
|
||||||
const token = await queryFn(
|
const { token } = await queryFn({
|
||||||
{
|
|
||||||
path: ["auth", action],
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
url: `${apiUrl}/auth/${action === "login" ? "sessions" : "users"}`,
|
||||||
body,
|
body,
|
||||||
authenticated: false,
|
authToken: null,
|
||||||
apiUrl,
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
},
|
parser: z.object({ token: z.string() }),
|
||||||
TokenP,
|
});
|
||||||
);
|
|
||||||
const user = await queryFn({
|
const user = await queryFn({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: ["auth", "me"],
|
url: `${apiUrl}/auth/users/me`,
|
||||||
apiUrl,
|
authToken: token,
|
||||||
authToken: token.access_token,
|
parser: User,
|
||||||
parser: UserP,
|
|
||||||
});
|
});
|
||||||
const account: Account = { ...user, apiUrl: apiUrl, token, selected: true };
|
const account: Account = { ...user, apiUrl, token, selected: true };
|
||||||
addAccount(account);
|
addAccount(account);
|
||||||
return { ok: true, value: account };
|
return { ok: true, value: account };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(action, e);
|
console.error(action, e);
|
||||||
return { ok: false, error: (e as KyooErrors).errors[0] };
|
return { ok: false, error: (e as KyooError).message };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const oidcLogin = async (provider: string, code: string, apiUrl?: string) => {
|
// export const oidcLogin = async (
|
||||||
if (!apiUrl || apiUrl.length === 0) apiUrl = getCurrentApiUrl()!;
|
// provider: string,
|
||||||
try {
|
// code: string,
|
||||||
const token = await queryFn(
|
// apiUrl?: string,
|
||||||
{
|
// ) => {
|
||||||
path: ["auth", "callback", provider, `?code=${code}`],
|
// if (!apiUrl || apiUrl.length === 0) apiUrl = getCurrentApiUrl()!;
|
||||||
method: "POST",
|
// try {
|
||||||
authenticated: false,
|
// const token = await queryFn(
|
||||||
apiUrl,
|
// {
|
||||||
},
|
// path: ["auth", "callback", provider, `?code=${code}`],
|
||||||
TokenP,
|
// method: "POST",
|
||||||
);
|
// authenticated: false,
|
||||||
const user = await queryFn(
|
// apiUrl,
|
||||||
{ path: ["auth", "me"], method: "GET", apiUrl },
|
// },
|
||||||
UserP,
|
// TokenP,
|
||||||
`Bearer ${token.access_token}`,
|
// );
|
||||||
);
|
// const user = await queryFn(
|
||||||
const account: Account = { ...user, apiUrl: apiUrl, token, selected: true };
|
// { path: ["auth", "me"], method: "GET", apiUrl },
|
||||||
addAccount(account);
|
// UserP,
|
||||||
return { ok: true, value: account };
|
// `Bearer ${token.access_token}`,
|
||||||
} catch (e) {
|
// );
|
||||||
console.error("oidcLogin", e);
|
// const account: Account = { ...user, apiUrl: apiUrl, token, selected: true };
|
||||||
return { ok: false, error: (e as KyooErrors).errors[0] };
|
// addAccount(account);
|
||||||
}
|
// return { ok: true, value: account };
|
||||||
};
|
// } catch (e) {
|
||||||
|
// console.error("oidcLogin", e);
|
||||||
|
// return { ok: false, error: (e as KyooErrors).errors[0] };
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
export const logout = () => {
|
export const logout = () => {
|
||||||
removeAccounts((x) => x.selected);
|
removeAccounts((x) => x.selected);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteAccount = async () => {
|
export const deleteAccount = async () => {
|
||||||
await queryFn({ path: ["auth", "me"], method: "DELETE" });
|
// await queryFn({
|
||||||
|
// method: "DELETE",
|
||||||
|
// url: "auth/users/me",
|
||||||
|
// parser: null,
|
||||||
|
// });
|
||||||
logout();
|
logout();
|
||||||
};
|
};
|
||||||
|
@ -1,51 +1,41 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useRouter } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { percent, px, useYoshiki } from "yoshiki/native";
|
import { percent, px, useYoshiki } from "yoshiki/native";
|
||||||
import { type QueryPage, login } from "~/models";
|
|
||||||
import { A, Button, H1, Input, P, ts } from "~/primitives";
|
import { A, Button, H1, Input, P, ts } from "~/primitives";
|
||||||
|
import { useQueryState } from "~/utils";
|
||||||
import { FormPage } from "./form";
|
import { FormPage } from "./form";
|
||||||
import { OidcLogin } from "./oidc";
|
import { login } from "./logic";
|
||||||
import { PasswordInput } from "./password-input";
|
import { PasswordInput } from "./password-input";
|
||||||
|
import { ServerUrlPage } from "./server-url";
|
||||||
|
|
||||||
export const LoginPage: QueryPage<{ apiUrl?: string; error?: string }> = ({
|
export const LoginPage = () => {
|
||||||
apiUrl,
|
const [apiUrl] = useQueryState("apiUrl", null);
|
||||||
error: initialError,
|
|
||||||
}) => {
|
|
||||||
const { data } = useFetch({
|
|
||||||
path: ["info"],
|
|
||||||
parser: ServerInfoP,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | undefined>(initialError);
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { css } = useYoshiki();
|
const { css } = useYoshiki();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
if (Platform.OS !== "web" && !apiUrl) return <ServerUrlPage />;
|
||||||
if (!apiUrl && Platform.OS !== "web")
|
|
||||||
router.replace("/server-url", undefined, {
|
|
||||||
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false },
|
|
||||||
});
|
|
||||||
}, [apiUrl, router]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormPage apiUrl={apiUrl}>
|
<FormPage apiUrl={apiUrl!}>
|
||||||
<H1>{t("login.login")}</H1>
|
<H1>{t("login.login")}</H1>
|
||||||
<OidcLogin apiUrl={apiUrl} hideOr={!data?.passwordLoginEnabled} />
|
{/* <OidcLogin apiUrl={apiUrl} hideOr={!data?.passwordLoginEnabled} /> */}
|
||||||
{data?.passwordLoginEnabled && (
|
{/* {data?.passwordLoginEnabled && ( */}
|
||||||
<>
|
{/* <> */}
|
||||||
<P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P>
|
<P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.username")}</P>
|
||||||
<Input
|
<Input
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
variant="big"
|
variant="big"
|
||||||
onChangeText={(value) => setUsername(value)}
|
onChangeText={(value) => setUsername(value)}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
/>
|
/>
|
||||||
<P {...css({ paddingLeft: ts(1) })}>{t("login.password")}</P>
|
<P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.password")}</P>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
autoComplete="password"
|
autoComplete="password"
|
||||||
variant="big"
|
variant="big"
|
||||||
@ -56,15 +46,13 @@ export const LoginPage: QueryPage<{ apiUrl?: string; error?: string }> = ({
|
|||||||
text={t("login.login")}
|
text={t("login.login")}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
const { error } = await login("login", {
|
const { error } = await login("login", {
|
||||||
username,
|
login: username,
|
||||||
password,
|
password,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
});
|
});
|
||||||
setError(error);
|
setError(error);
|
||||||
if (error) return;
|
if (error) return;
|
||||||
router.replace("/", undefined, {
|
router.replace("/");
|
||||||
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false },
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
{...css({
|
{...css({
|
||||||
m: ts(1),
|
m: ts(1),
|
||||||
@ -74,16 +62,14 @@ export const LoginPage: QueryPage<{ apiUrl?: string; error?: string }> = ({
|
|||||||
mY: ts(3),
|
mY: ts(3),
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</>
|
{/* </> */}
|
||||||
)}
|
{/* )} */}
|
||||||
<P>
|
<P>
|
||||||
<Trans i18nKey="login.or-register">
|
<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>
|
</Trans>
|
||||||
</P>
|
</P>
|
||||||
</FormPage>
|
</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 VisibilityOff from "@material-symbols/svg-400/rounded/visibility_off-fill.svg";
|
||||||
|
import Visibility from "@material-symbols/svg-400/rounded/visibility-fill.svg";
|
||||||
import { type ComponentProps, useState } from "react";
|
import { type ComponentProps, useState } from "react";
|
||||||
import { px, useYoshiki } from "yoshiki/native";
|
import { px, useYoshiki } from "yoshiki/native";
|
||||||
|
import { IconButton, Input } from "~/primitives";
|
||||||
|
|
||||||
export const PasswordInput = (props: ComponentProps<typeof Input>) => {
|
export const PasswordInput = (props: ComponentProps<typeof Input>) => {
|
||||||
const { css } = useYoshiki();
|
const { css } = useYoshiki();
|
||||||
|
@ -1,21 +1,17 @@
|
|||||||
import { type QueryPage, login } from "@kyoo/models";
|
import { useRouter } from "expo-router";
|
||||||
import { A, Button, H1, Input, P, ts } from "@kyoo/primitives";
|
import { useState } from "react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { useRouter } from "solito/router";
|
|
||||||
import { percent, px, useYoshiki } from "yoshiki/native";
|
import { percent, px, useYoshiki } from "yoshiki/native";
|
||||||
import { DefaultLayout } from "../../../packages/ui/src/layout";
|
import { A, Button, H1, Input, P, ts } from "~/primitives";
|
||||||
|
import { useQueryState } from "~/utils";
|
||||||
import { FormPage } from "./form";
|
import { FormPage } from "./form";
|
||||||
import { OidcLogin } from "./oidc";
|
import { login } from "./logic";
|
||||||
import { PasswordInput } from "./password-input";
|
import { PasswordInput } from "./password-input";
|
||||||
|
import { ServerUrlPage } from "./server-url";
|
||||||
|
|
||||||
export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => {
|
export const RegisterPage = () => {
|
||||||
const { data } = useFetch({
|
const [apiUrl] = useQueryState("apiUrl", null);
|
||||||
path: ["info"],
|
|
||||||
parser: ServerInfoP,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@ -26,37 +22,36 @@ export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { css } = useYoshiki();
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
useEffect(() => {
|
if (Platform.OS !== "web" && !apiUrl) return <ServerUrlPage />;
|
||||||
if (!apiUrl && Platform.OS !== "web")
|
|
||||||
router.replace("/server-url", undefined, {
|
|
||||||
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false },
|
|
||||||
});
|
|
||||||
}, [apiUrl, router]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormPage apiUrl={apiUrl}>
|
<FormPage apiUrl={apiUrl!}>
|
||||||
<H1>{t("login.register")}</H1>
|
<H1>{t("login.register")}</H1>
|
||||||
<OidcLogin apiUrl={apiUrl} hideOr={!data?.passwordLoginEnabled} />
|
{/* <OidcLogin apiUrl={apiUrl} hideOr={!data?.passwordLoginEnabled} /> */}
|
||||||
{data?.registrationEnabled && (
|
{/* {data?.registrationEnabled && ( */}
|
||||||
<>
|
{/* <> */}
|
||||||
<P {...css({ paddingLeft: ts(1) })}>{t("login.username")}</P>
|
<P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.username")}</P>
|
||||||
<Input
|
<Input
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
variant="big"
|
variant="big"
|
||||||
onChangeText={(value) => setUsername(value)}
|
onChangeText={(value) => setUsername(value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<P {...css({ paddingLeft: ts(1) })}>{t("login.email")}</P>
|
<P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.email")}</P>
|
||||||
<Input autoComplete="email" variant="big" onChangeText={(value) => setEmail(value)} />
|
<Input
|
||||||
|
autoComplete="email"
|
||||||
|
variant="big"
|
||||||
|
onChangeText={(value) => setEmail(value)}
|
||||||
|
/>
|
||||||
|
|
||||||
<P {...css({ paddingLeft: ts(1) })}>{t("login.password")}</P>
|
<P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.password")}</P>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
autoComplete="password-new"
|
autoComplete="password-new"
|
||||||
variant="big"
|
variant="big"
|
||||||
onChangeText={(value) => setPassword(value)}
|
onChangeText={(value) => setPassword(value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<P {...css({ paddingLeft: ts(1) })}>{t("login.confirm")}</P>
|
<P {...(css({ paddingLeft: ts(1) }) as any)}>{t("login.confirm")}</P>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
autoComplete="password-new"
|
autoComplete="password-new"
|
||||||
variant="big"
|
variant="big"
|
||||||
@ -64,19 +59,24 @@ export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{password !== confirm && (
|
{password !== confirm && (
|
||||||
<P {...css({ color: (theme) => theme.colors.red })}>{t("login.password-no-match")}</P>
|
<P {...css({ color: (theme) => theme.colors.red })}>
|
||||||
|
{t("login.password-no-match")}
|
||||||
|
</P>
|
||||||
)}
|
)}
|
||||||
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>}
|
{error && <P {...css({ color: (theme) => theme.colors.red })}>{error}</P>}
|
||||||
<Button
|
<Button
|
||||||
text={t("login.register")}
|
text={t("login.register")}
|
||||||
disabled={password !== confirm}
|
disabled={password !== confirm}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
const { error } = await login("register", { email, username, password, apiUrl });
|
const { error } = await login("register", {
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
apiUrl,
|
||||||
|
});
|
||||||
setError(error);
|
setError(error);
|
||||||
if (error) return;
|
if (error) return;
|
||||||
router.replace("/", undefined, {
|
router.replace("/");
|
||||||
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false },
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
{...css({
|
{...css({
|
||||||
m: ts(1),
|
m: ts(1),
|
||||||
@ -86,17 +86,14 @@ export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => {
|
|||||||
mY: ts(3),
|
mY: ts(3),
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</>
|
{/* </> */}
|
||||||
)}
|
{/* )} */}
|
||||||
<P>
|
<P>
|
||||||
<Trans i18nKey="login.or-login">
|
<Trans i18nKey="login.or-login">
|
||||||
Have an account already? <A href={{ pathname: "/login", query: { apiUrl } }}>Log in</A>.
|
Have an account already?{" "}
|
||||||
|
<A href={`/login?apiUrl=${apiUrl}`}>Log in</A>.
|
||||||
</Trans>
|
</Trans>
|
||||||
</P>
|
</P>
|
||||||
</FormPage>
|
</FormPage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
RegisterPage.getFetchUrls = () => [OidcLogin.query()];
|
|
||||||
RegisterPage.isPublic = true;
|
|
||||||
RegisterPage.getLayout = DefaultLayout;
|
|
||||||
|
@ -1,28 +1,23 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ImageBackground, Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { type Theme, percent, useYoshiki } from "yoshiki/native";
|
import { type Theme, useYoshiki } from "yoshiki/native";
|
||||||
import { type ServerInfo, ServerInfoP } from "~/models";
|
import { Button, H1, Input, Link, P, ts } from "~/primitives";
|
||||||
import { Button, H1, HR, Input, Link, P, ts } from "~/primitives";
|
import { type QueryIdentifier, useFetch } from "~/query";
|
||||||
import { DefaultLayout } from "../../../packages/ui/src/layout";
|
|
||||||
|
|
||||||
export const cleanApiUrl = (apiUrl: string) => {
|
export const cleanApiUrl = (apiUrl: string) => {
|
||||||
if (Platform.OS === "web") return undefined;
|
if (Platform.OS === "web") return undefined;
|
||||||
if (!/https?:\/\//.test(apiUrl)) apiUrl = `http://${apiUrl}`;
|
if (!/https?:\/\//.test(apiUrl)) apiUrl = `http://${apiUrl}`;
|
||||||
apiUrl = apiUrl.replace(/\/$/, "");
|
return apiUrl.replace(/\/$/, "");
|
||||||
return `${apiUrl}/api`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const query: QueryIdentifier<ServerInfo> = {
|
export const ServerUrlPage = () => {
|
||||||
path: ["info"],
|
|
||||||
parser: ServerInfoP,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ServerUrlPage: QueryPage = () => {
|
|
||||||
const [_apiUrl, setApiUrl] = useState("");
|
const [_apiUrl, setApiUrl] = useState("");
|
||||||
const apiUrl = cleanApiUrl(_apiUrl);
|
const apiUrl = cleanApiUrl(_apiUrl);
|
||||||
const { data, error } = useFetch({ ...query, options: { apiUrl, authenticated: false } });
|
const { data, error } = useFetch({
|
||||||
const router = useRouter();
|
...ServerUrlPage.query,
|
||||||
|
options: { apiUrl, authToken: null },
|
||||||
|
});
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { css } = useYoshiki();
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
@ -36,48 +31,56 @@ export const ServerUrlPage: QueryPage = () => {
|
|||||||
>
|
>
|
||||||
<H1>{t("login.server")}</H1>
|
<H1>{t("login.server")}</H1>
|
||||||
<View {...css({ justifyContent: "center" })}>
|
<View {...css({ justifyContent: "center" })}>
|
||||||
<Input variant="big" onChangeText={setApiUrl} autoCorrect={false} autoCapitalize="none" />
|
<Input
|
||||||
|
variant="big"
|
||||||
|
onChangeText={setApiUrl}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
{!data && (
|
{!data && (
|
||||||
<P {...css({ color: (theme: Theme) => theme.colors.red, alignSelf: "center" })}>
|
<P
|
||||||
{error?.errors[0] ?? t("misc.loading")}
|
{...css({
|
||||||
|
color: (theme: Theme) => theme.colors.red,
|
||||||
|
alignSelf: "center",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{error?.message ?? t("misc.loading")}
|
||||||
</P>
|
</P>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View {...css({ marginTop: ts(5) })}>
|
<View {...css({ marginTop: ts(5) })}>
|
||||||
<View {...css({ flexDirection: "row", width: percent(100), alignItems: "center" })}>
|
{/* <View {...css({ flexDirection: "row", width: percent(100), alignItems: "center" })}> */}
|
||||||
{data &&
|
{/* {data && */}
|
||||||
Object.values(data.oidc).map((x) => (
|
{/* Object.values(data.oidc).map((x) => ( */}
|
||||||
<Link
|
{/* <Link */}
|
||||||
key={x.displayName}
|
{/* key={x.displayName} */}
|
||||||
href={{ pathname: x.link, query: { apiUrl } }}
|
{/* href={{ pathname: x.link, query: { apiUrl } }} */}
|
||||||
{...css({ justifyContent: "center" })}
|
{/* {...css({ justifyContent: "center" })} */}
|
||||||
>
|
{/* > */}
|
||||||
{x.logoUrl != null ? (
|
{/* {x.logoUrl != null ? ( */}
|
||||||
<ImageBackground
|
{/* <ImageBackground */}
|
||||||
source={{ uri: x.logoUrl }}
|
{/* source={{ uri: x.logoUrl }} */}
|
||||||
{...css({ width: ts(3), height: ts(3), margin: ts(1) })}
|
{/* {...css({ width: ts(3), height: ts(3), margin: ts(1) })} */}
|
||||||
/>
|
{/* /> */}
|
||||||
) : (
|
{/* ) : ( */}
|
||||||
t("login.via", { provider: x.displayName })
|
{/* t("login.via", { provider: x.displayName }) */}
|
||||||
)}
|
{/* )} */}
|
||||||
</Link>
|
{/* </Link> */}
|
||||||
))}
|
{/* ))} */}
|
||||||
</View>
|
{/* </View> */}
|
||||||
<HR />
|
{/* <HR /> */}
|
||||||
<View {...css({ flexDirection: "row", gap: ts(2) })}>
|
<View {...css({ flexDirection: "row", gap: ts(2) })}>
|
||||||
<Button
|
<Button
|
||||||
text={t("login.login")}
|
text={t("login.login")}
|
||||||
onPress={() => {
|
as={Link}
|
||||||
router.push(`/login?apiUrl=${apiUrl}`);
|
href={`/login?apiUrl=${apiUrl}`}
|
||||||
}}
|
|
||||||
disabled={data == null}
|
disabled={data == null}
|
||||||
{...css({ flexGrow: 1, flexShrink: 1 })}
|
{...css({ flexGrow: 1, flexShrink: 1 })}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
text={t("login.register")}
|
text={t("login.register")}
|
||||||
onPress={() => {
|
as={Link}
|
||||||
router.push(`/register?apiUrl=${apiUrl}`);
|
href={`/register?apiUrl=${apiUrl}`}
|
||||||
}}
|
|
||||||
disabled={data == null}
|
disabled={data == null}
|
||||||
{...css({ flexGrow: 1, flexShrink: 1 })}
|
{...css({ flexGrow: 1, flexShrink: 1 })}
|
||||||
/>
|
/>
|
||||||
@ -88,4 +91,7 @@ export const ServerUrlPage: QueryPage = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ServerUrlPage.getLayout = DefaultLayout;
|
ServerUrlPage.query = {
|
||||||
|
path: ["api", "health"],
|
||||||
|
parser: null,
|
||||||
|
} satisfies QueryIdentifier;
|
||||||
|
@ -4,15 +4,20 @@ import Login from "@material-symbols/svg-400/rounded/login.svg";
|
|||||||
import Logout from "@material-symbols/svg-400/rounded/logout.svg";
|
import Logout from "@material-symbols/svg-400/rounded/logout.svg";
|
||||||
import Search from "@material-symbols/svg-400/rounded/search-fill.svg";
|
import Search from "@material-symbols/svg-400/rounded/search-fill.svg";
|
||||||
import Settings from "@material-symbols/svg-400/rounded/settings.svg";
|
import Settings from "@material-symbols/svg-400/rounded/settings.svg";
|
||||||
import type { ReactElement, Ref } from "react";
|
import type { Ref } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, type TextInput, type TextInputProps, View, type ViewProps } from "react-native";
|
import {
|
||||||
import { type Stylable, percent, useYoshiki } from "yoshiki/native";
|
Platform,
|
||||||
|
type TextInput,
|
||||||
|
type TextInputProps,
|
||||||
|
View,
|
||||||
|
type ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { type Theme, useYoshiki } from "yoshiki/native";
|
||||||
import {
|
import {
|
||||||
A,
|
A,
|
||||||
Avatar,
|
Avatar,
|
||||||
HR,
|
HR,
|
||||||
Header,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
Link,
|
Link,
|
||||||
@ -21,29 +26,30 @@ import {
|
|||||||
tooltip,
|
tooltip,
|
||||||
ts,
|
ts,
|
||||||
} from "~/primitives";
|
} from "~/primitives";
|
||||||
import { useAccount, useAccounts, useHasPermission } from "~/providers/account-context";
|
import { useAccount, useAccounts } from "~/providers/account-context";
|
||||||
import { logout } from "~/ui/login/logic";
|
import { logout } from "~/ui/login/logic";
|
||||||
import { useQueryState } from "~/utils";
|
import { useQueryState } from "~/utils";
|
||||||
// import { AdminPage } from "../../../packages/ui/src/admin/packages/ui/src/admin";
|
|
||||||
import { KyooLongLogo } from "./icon";
|
import { KyooLongLogo } from "./icon";
|
||||||
|
|
||||||
export const NavbarTitle = (props: Stylable & { onLayout?: ViewProps["onLayout"] }) => {
|
export const NavbarTitle = (props: { onLayout?: ViewProps["onLayout"] }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { css } = useYoshiki();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<A
|
<A
|
||||||
href="/"
|
href="/"
|
||||||
aria-label={t("navbar.home")}
|
aria-label={t("navbar.home")}
|
||||||
{...tooltip(t("navbar.home"))}
|
{...tooltip(t("navbar.home"))}
|
||||||
{...css({ fontSize: 0 }, props)}
|
{...props}
|
||||||
>
|
>
|
||||||
<KyooLongLogo />
|
<KyooLongLogo />
|
||||||
</A>
|
</A>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SearchBar = ({ ref, ...props }: TextInputProps & { ref?: Ref<TextInput> }) => {
|
const SearchBar = ({
|
||||||
|
ref,
|
||||||
|
...props
|
||||||
|
}: TextInputProps & { ref?: Ref<TextInput> }) => {
|
||||||
const { theme } = useYoshiki();
|
const { theme } = useYoshiki();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [query, setQuery] = useQueryState("q", "");
|
const [query, setQuery] = useQueryState("q", "");
|
||||||
@ -55,7 +61,11 @@ const SearchBar = ({ ref, ...props }: TextInputProps & { ref?: Ref<TextInput> })
|
|||||||
onChangeText={setQuery}
|
onChangeText={setQuery}
|
||||||
placeholder={t("navbar.search")}
|
placeholder={t("navbar.search")}
|
||||||
placeholderTextColor={theme.contrast}
|
placeholderTextColor={theme.contrast}
|
||||||
containerStyle={{ height: ts(4), flexShrink: 1, borderColor: (theme) => theme.contrast }}
|
containerStyle={{
|
||||||
|
height: ts(4),
|
||||||
|
flexShrink: 1,
|
||||||
|
borderColor: (theme: Theme) => theme.contrast,
|
||||||
|
}}
|
||||||
{...tooltip(t("navbar.search"))}
|
{...tooltip(t("navbar.search"))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@ -89,7 +99,11 @@ export const NavbarProfile = () => {
|
|||||||
{accounts?.map((x) => (
|
{accounts?.map((x) => (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
key={x.id}
|
key={x.id}
|
||||||
label={Platform.OS === "web" ? x.username : `${x.username} - ${getDisplayUrl(x.apiUrl)}`}
|
label={
|
||||||
|
Platform.OS === "web"
|
||||||
|
? x.username
|
||||||
|
: `${x.username} - ${getDisplayUrl(x.apiUrl)}`
|
||||||
|
}
|
||||||
left={<Avatar placeholder={x.username} src={x.logo} />}
|
left={<Avatar placeholder={x.username} src={x.logo} />}
|
||||||
selected={x.selected}
|
selected={x.selected}
|
||||||
onSelect={() => x.select()}
|
onSelect={() => x.select()}
|
||||||
@ -100,12 +114,24 @@ export const NavbarProfile = () => {
|
|||||||
{!account ? (
|
{!account ? (
|
||||||
<>
|
<>
|
||||||
<Menu.Item label={t("login.login")} icon={Login} href="/login" />
|
<Menu.Item label={t("login.login")} icon={Login} href="/login" />
|
||||||
<Menu.Item label={t("login.register")} icon={Register} href="/register" />
|
<Menu.Item
|
||||||
|
label={t("login.register")}
|
||||||
|
icon={Register}
|
||||||
|
href="/register"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Menu.Item label={t("login.add-account")} icon={Login} href="/login" />
|
<Menu.Item
|
||||||
<Menu.Item label={t("login.logout")} icon={Logout} onSelect={logout} />
|
label={t("login.add-account")}
|
||||||
|
icon={Login}
|
||||||
|
href="/login"
|
||||||
|
/>
|
||||||
|
<Menu.Item
|
||||||
|
label={t("login.logout")}
|
||||||
|
icon={Logout}
|
||||||
|
onSelect={logout}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Menu>
|
</Menu>
|
||||||
@ -117,7 +143,9 @@ export const NavbarRight = () => {
|
|||||||
const isAdmin = false; //useHasPermission(AdminPage.requiredPermissions);
|
const isAdmin = false; //useHasPermission(AdminPage.requiredPermissions);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...css({ flexDirection: "row", alignItems: "center", flexShrink: 1 })}>
|
<View
|
||||||
|
{...css({ flexDirection: "row", alignItems: "center", flexShrink: 1 })}
|
||||||
|
>
|
||||||
{Platform.OS === "web" ? (
|
{Platform.OS === "web" ? (
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
) : (
|
) : (
|
||||||
@ -143,72 +171,78 @@ export const NavbarRight = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Navbar = ({
|
// export const Navbar = ({
|
||||||
left,
|
// left,
|
||||||
right,
|
// right,
|
||||||
background,
|
// background,
|
||||||
...props
|
// ...props
|
||||||
}: {
|
// }: {
|
||||||
left?: ReactElement | null;
|
// left?: ReactElement | null;
|
||||||
right?: ReactElement | null;
|
// right?: ReactElement | null;
|
||||||
background?: ReactElement;
|
// background?: ReactElement;
|
||||||
} & Stylable) => {
|
// } & Stylable) => {
|
||||||
const { css } = useYoshiki();
|
// const { css } = useYoshiki();
|
||||||
const { t } = useTranslation();
|
// const { t } = useTranslation();
|
||||||
|
//
|
||||||
return (
|
// return (
|
||||||
<Header
|
// <Header
|
||||||
{...css(
|
// {...css(
|
||||||
{
|
// {
|
||||||
backgroundColor: (theme) => theme.accent,
|
// backgroundColor: (theme) => theme.accent,
|
||||||
paddingX: ts(2),
|
// paddingX: ts(2),
|
||||||
height: { xs: 48, sm: 64 },
|
// height: { xs: 48, sm: 64 },
|
||||||
flexDirection: "row",
|
// flexDirection: "row",
|
||||||
justifyContent: { xs: "space-between", sm: "flex-start" },
|
// justifyContent: { xs: "space-between", sm: "flex-start" },
|
||||||
alignItems: "center",
|
// alignItems: "center",
|
||||||
shadowColor: "#000",
|
// shadowColor: "#000",
|
||||||
shadowOffset: {
|
// shadowOffset: {
|
||||||
width: 0,
|
// width: 0,
|
||||||
height: 4,
|
// height: 4,
|
||||||
},
|
// },
|
||||||
shadowOpacity: 0.3,
|
// shadowOpacity: 0.3,
|
||||||
shadowRadius: 4.65,
|
// shadowRadius: 4.65,
|
||||||
elevation: 8,
|
// elevation: 8,
|
||||||
zIndex: 1,
|
// zIndex: 1,
|
||||||
},
|
// },
|
||||||
props,
|
// props,
|
||||||
)}
|
// )}
|
||||||
>
|
// >
|
||||||
{background}
|
// {background}
|
||||||
<View {...css({ flexDirection: "row", alignItems: "center", height: percent(100) })}>
|
// <View
|
||||||
{left !== undefined ? (
|
// {...css({
|
||||||
left
|
// flexDirection: "row",
|
||||||
) : (
|
// alignItems: "center",
|
||||||
<>
|
// height: percent(100),
|
||||||
<NavbarTitle {...css({ marginX: ts(2) })} />
|
// })}
|
||||||
<A
|
// >
|
||||||
href="/browse"
|
// {left !== undefined ? (
|
||||||
{...css({
|
// left
|
||||||
textTransform: "uppercase",
|
// ) : (
|
||||||
fontWeight: "bold",
|
// <>
|
||||||
color: (theme) => theme.contrast,
|
// <NavbarTitle {...css({ marginX: ts(2) })} />
|
||||||
})}
|
// <A
|
||||||
>
|
// href="/browse"
|
||||||
{t("navbar.browse")}
|
// {...css({
|
||||||
</A>
|
// textTransform: "uppercase",
|
||||||
</>
|
// fontWeight: "bold",
|
||||||
)}
|
// color: (theme) => theme.contrast,
|
||||||
</View>
|
// })}
|
||||||
<View
|
// >
|
||||||
{...css({
|
// {t("navbar.browse")}
|
||||||
flexGrow: 1,
|
// </A>
|
||||||
flexShrink: 1,
|
// </>
|
||||||
flexDirection: "row",
|
// )}
|
||||||
display: { xs: "none", sm: "flex" },
|
// </View>
|
||||||
marginX: ts(2),
|
// <View
|
||||||
})}
|
// {...css({
|
||||||
/>
|
// flexGrow: 1,
|
||||||
{right !== undefined ? right : <NavbarRight />}
|
// flexShrink: 1,
|
||||||
</Header>
|
// flexDirection: "row",
|
||||||
);
|
// display: { xs: "none", sm: "flex" },
|
||||||
};
|
// marginX: ts(2),
|
||||||
|
// })}
|
||||||
|
// />
|
||||||
|
// {right !== undefined ? right : <NavbarRight />}
|
||||||
|
// </Header>
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
Loading…
x
Reference in New Issue
Block a user