Add a multiaccount menu on mobile

This commit is contained in:
Zoe Roux 2023-07-13 17:57:11 +09:00
parent 12f685010b
commit fee59833f2
7 changed files with 60 additions and 14 deletions

View File

@ -24,6 +24,7 @@ import { zdate } from "./utils";
import { queryFn } from "./query"; import { queryFn } from "./query";
import { KyooErrors } from "./kyoo-errors"; import { KyooErrors } from "./kyoo-errors";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useEffect, useState } from "react";
const TokenP = z.object({ const TokenP = z.object({
token_type: z.literal("Bearer"), token_type: z.literal("Bearer"),
@ -38,8 +39,21 @@ type Result<A, B> =
| { ok: true; value: A; error?: undefined } | { ok: true; value: A; error?: undefined }
| { ok: false; value?: undefined; error: B }; | { ok: false; value?: undefined; error: B };
export type Account = Token & { apiUrl: string; username: string | null }; export type Account = Token & { apiUrl: string; username: string };
export const useAccounts = () => {
const [accounts, setAccounts] = useState<Account[]>([]);
const [selected, setSelected] = useState(0);
useEffect(() => {
async function run() {
setAccounts(JSON.parse(await getSecureItem("accounts") ?? "[]"));
}
run();
}, []);
return {accounts, selected, setSelected};
}
const addAccount = async (token: Token, apiUrl: string, username: string | null): Promise<void> => { const addAccount = async (token: Token, apiUrl: string, username: string | null): Promise<void> => {
const accounts: Account[] = JSON.parse(await getSecureItem("accounts") ?? "[]"); const accounts: Account[] = JSON.parse(await getSecureItem("accounts") ?? "[]");

View File

@ -72,7 +72,8 @@ export const Avatar = forwardRef<
[ [
{ {
borderRadius: 999999, borderRadius: 999999,
p: ts(1), height: size,
width: size,
}, },
fill && { fill && {
bg: col, bg: col,
@ -92,8 +93,6 @@ export const Avatar = forwardRef<
<P <P
{...css({ {...css({
marginVertical: 0, marginVertical: 0,
height: size,
width: size,
lineHeight: size, lineHeight: size,
textAlign: "center", textAlign: "center",
})} })}

View File

@ -20,7 +20,7 @@
import { Portal } from "@gorhom/portal"; import { Portal } from "@gorhom/portal";
import { ScrollView } from "moti"; import { ScrollView } from "moti";
import { ComponentType, createContext, ReactNode, useContext, useEffect, useState } from "react"; import { ComponentType, createContext, ReactElement, ReactNode, useContext, useEffect, useState } from "react";
import { StyleSheet, Pressable } from "react-native"; import { StyleSheet, Pressable } from "react-native";
import { percent, px, sm, useYoshiki, xl } from "yoshiki/native"; import { percent, px, sm, useYoshiki, xl } from "yoshiki/native";
import Close from "@material-symbols/svg-400/rounded/close-fill.svg"; import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
@ -114,6 +114,7 @@ const Menu = <AsProps,>({
const MenuItem = ({ const MenuItem = ({
label, label,
selected, selected,
left,
onSelect, onSelect,
href, href,
icon, icon,
@ -121,12 +122,15 @@ const MenuItem = ({
}: { }: {
label: string; label: string;
selected?: boolean; selected?: boolean;
left?: ReactElement;
icon?: ComponentType<SvgProps>; icon?: ComponentType<SvgProps>;
} & ({ onSelect: () => void; href?: undefined } | { href: string; onSelect?: undefined })) => { } & ({ onSelect: () => void; href?: undefined } | { href: string; onSelect?: undefined })) => {
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
const setOpen = useContext(MenuContext); const setOpen = useContext(MenuContext);
const router = useRouter(); const router = useRouter();
const icn = (icon || selected) && <Icon icon={icon ?? Check} color={theme.paragraph} size={24} {...css({ paddingLeft: icon ? ts(2) : 0 })}/>;
return ( return (
<PressableFeedback <PressableFeedback
onPress={() => { onPress={() => {
@ -145,8 +149,10 @@ const MenuItem = ({
props as any, props as any,
)} )}
> >
{(icon || selected) && <Icon icon={icon ?? Check} color={theme.paragraph} size={24} {...css({ paddingLeft: icon ? ts(2) : 0 })}/>} {left && left}
<P {...css({ paddingLeft: ts(2) + +!(icon || selected) * px(24) })}>{label}</P> {!left && icn && icn}
<P {...css({ paddingLeft: ts(2) + +!(icon || selected || left) * px(24), flexGrow: 1 })}>{label}</P>
{left && icn && icn}
</PressableFeedback> </PressableFeedback>
); );
}; };

View File

@ -19,7 +19,7 @@
*/ */
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ComponentProps, ComponentType, forwardRef, ReactNode } from "react"; import { ComponentProps, ComponentType, forwardRef, ReactElement, ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { PressableProps } from "react-native"; import { PressableProps } from "react-native";
import { useYoshiki } from "yoshiki/web"; import { useYoshiki } from "yoshiki/web";
@ -119,6 +119,7 @@ const Item = forwardRef<
const MenuItem = ({ const MenuItem = ({
label, label,
icon, icon,
left,
selected, selected,
onSelect, onSelect,
href, href,
@ -126,6 +127,7 @@ const MenuItem = ({
}: { }: {
label: string; label: string;
icon?: ComponentType<SvgProps>; icon?: ComponentType<SvgProps>;
left?: ReactElement;
selected?: boolean; selected?: boolean;
} & ({ onSelect: () => void; href?: undefined } | { href: string; onSelect?: undefined })) => { } & ({ onSelect: () => void; href?: undefined } | { href: string; onSelect?: undefined })) => {
const { css: nCss } = useNativeYoshiki(); const { css: nCss } = useNativeYoshiki();
@ -152,6 +154,7 @@ const MenuItem = ({
props as any, props as any,
)} )}
> >
{left && left}
{(icon || selected) && ( {(icon || selected) && (
<Icon <Icon
icon={icon ?? Dot} icon={icon ?? Dot}
@ -160,7 +163,7 @@ const MenuItem = ({
{...nCss({ paddingRight: ts(1) })} {...nCss({ paddingRight: ts(1) })}
/> />
)} )}
{<P {...nCss(!selected && { paddingLeft: ts(1 + (icon ? 2 : 1)) })}>{label}</P>} {<P {...nCss(!(icon || selected || left) && { paddingLeft: ts(1) })}>{label}</P>}
</Item> </Item>
</> </>
); );

View File

@ -26,6 +26,7 @@ import {
Page, Page,
Paged, Paged,
QueryIdentifier, QueryIdentifier,
useAccounts,
User, User,
UserP, UserP,
} from "@kyoo/models"; } from "@kyoo/models";
@ -41,6 +42,7 @@ import {
ts, ts,
Menu, Menu,
PressableFeedback, PressableFeedback,
HR,
} from "@kyoo/primitives"; } from "@kyoo/primitives";
import { Platform, TextInput, View, ViewProps } from "react-native"; import { Platform, TextInput, View, ViewProps } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -49,6 +51,8 @@ import { useRouter } from "solito/router";
import { px, rem, Stylable, useYoshiki } from "yoshiki/native"; import { px, rem, Stylable, useYoshiki } from "yoshiki/native";
import MenuIcon from "@material-symbols/svg-400/rounded/menu-fill.svg"; import MenuIcon from "@material-symbols/svg-400/rounded/menu-fill.svg";
import Search from "@material-symbols/svg-400/rounded/search-fill.svg"; import Search from "@material-symbols/svg-400/rounded/search-fill.svg";
import Login from "@material-symbols/svg-400/rounded/login.svg";
import Register from "@material-symbols/svg-400/rounded/app_registration.svg";
import Logout from "@material-symbols/svg-400/rounded/logout.svg"; import Logout from "@material-symbols/svg-400/rounded/logout.svg";
import Delete from "@material-symbols/svg-400/rounded/delete.svg"; import Delete from "@material-symbols/svg-400/rounded/delete.svg";
import { Fetch, FetchNE } from "../fetch"; import { Fetch, FetchNE } from "../fetch";
@ -102,10 +106,17 @@ export const MeQuery: QueryIdentifier<User> = {
parser: UserP, parser: UserP,
}; };
const getDisplayUrl = (url: string) => {
url = url.replace(/\/api$/, "");
url = url.replace(/https?:\/\//, "");
return url;
};
export const NavbarProfile = () => { export const NavbarProfile = () => {
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { accounts, selected, setSelected } = useAccounts();
return ( return (
<FetchNE query={MeQuery}> <FetchNE query={MeQuery}>
@ -120,13 +131,24 @@ export const NavbarProfile = () => {
{...css({ marginLeft: ts(1), justifyContent: "center" })} {...css({ marginLeft: ts(1), justifyContent: "center" })}
{...tooltip(username ?? t("navbar.login"))} {...tooltip(username ?? t("navbar.login"))}
> >
{accounts.map((x, i) => (
<Menu.Item
key={x.refresh_token}
label={`${x.username} - ${getDisplayUrl(x.apiUrl)}`}
left={<Avatar placeholder={x.username} />}
selected={selected === i}
onSelect={() => setSelected(i)}
/>
))}
{accounts.length > 0 && <HR />}
{isGuest ? ( {isGuest ? (
<> <>
<Menu.Item label={t("login.login")} href="/login" /> <Menu.Item label={t("login.login")} icon={Login} href="/login" />
<Menu.Item label={t("login.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")} label={t("login.logout")}
icon={Logout} icon={Logout}

View File

@ -60,6 +60,7 @@
"login": { "login": {
"login": "Login", "login": "Login",
"register": "Register", "register": "Register",
"add-account": "Add account",
"logout": "Logout", "logout": "Logout",
"server": "Server Address", "server": "Server Address",
"email": "Email", "email": "Email",

View File

@ -60,6 +60,7 @@
"login": { "login": {
"login": "Connexion", "login": "Connexion",
"register": "Créer un compte", "register": "Créer un compte",
"add-account": "Ajouter un compte",
"logout": "Déconnexion", "logout": "Déconnexion",
"server": "Addresse du serveur", "server": "Addresse du serveur",
"email": "Email", "email": "Email",