Compare commits

...

4 Commits

Author SHA1 Message Date
Jorrin
b12562d249 dumb ci v2 2024-04-21 17:03:50 +02:00
Jorrin
100435af3c dumb ci 2024-04-21 17:03:10 +02:00
Jorrin
7cce25a261 account deletion 2024-04-21 17:00:33 +02:00
Jorrin
a89ef8a901 add login, register, logout and devices list (including remove device) 2024-04-21 16:42:07 +02:00
15 changed files with 657 additions and 201 deletions

View File

@@ -1,60 +1,10 @@
import { Link } from "expo-router";
import { H3, H5, Paragraph, View } from "tamagui";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
import { MWInput } from "~/components/ui/Input";
import { AccountInformation } from "~/components/account/AccountInformation";
import { AccountGetStarted } from "~/components/account/GetStarted";
import { useAuthStore } from "~/stores/settings";
export default function MovieWebScreen() {
const { backendUrl, setBackendUrl } = useAuthStore();
const account = useAuthStore((state) => state.account);
return (
<ScreenLayout
contentContainerStyle={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<MWCard bordered padded>
<MWCard.Header>
<H3 fontWeight="$bold" paddingBottom="$1">
Sync to the cloud
</H3>
<H5 color="$shade200" fontWeight="$semibold" paddingVertical="$3">
Share your watch progress between devices and keep them synced.
</H5>
<Paragraph color="$shade200">
First choose the backend you want to use. If you do not know what
this does, use the default and click on &apos;Get started&apos;.
</Paragraph>
</MWCard.Header>
<View padding="$4">
<MWInput
placeholder={backendUrl}
type="authentication"
value={backendUrl}
onChangeText={setBackendUrl}
/>
</View>
<MWCard.Footer padded justifyContent="center">
<Link
href={{
pathname: "/sync/trust/[url]",
params: { url: backendUrl },
}}
asChild
>
<MWButton type="purple" width="100%">
Get started
</MWButton>
</Link>
</MWCard.Footer>
</MWCard>
</ScreenLayout>
);
if (account) return <AccountInformation />;
return <AccountGetStarted />;
}

View File

@@ -1,12 +1,40 @@
import { Stack } from "expo-router";
import { useState } from "react";
import { Link, Stack, useRouter } from "expo-router";
import { useMutation } from "@tanstack/react-query";
import { H4, Label, Paragraph, Text, YStack } from "tamagui";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
import { MWInput } from "~/components/ui/Input";
import { useAuth } from "~/hooks/useAuth";
import { useAuthStore } from "~/stores/settings";
export default function Page() {
const backendUrl = useAuthStore((state) => state.backendUrl);
const router = useRouter();
const { login } = useAuth();
const [passphrase, setPassphrase] = useState("");
const [deviceName, setDeviceName] = useState("");
const mutation = useMutation({
mutationKey: ["login", backendUrl, passphrase, deviceName],
mutationFn: () =>
login({
mnemonic: passphrase,
userData: {
device: deviceName,
},
}),
onSuccess: (data) => {
if (data) {
return router.push("/(tabs)/movie-web");
}
return null;
},
});
return (
<ScreenLayout
showHeader={false}
@@ -46,6 +74,8 @@ export default function Page() {
placeholder="Passphrase"
secureTextEntry
autoCorrect={false}
value={passphrase}
onChangeText={setPassphrase}
/>
</YStack>
<YStack gap="$1">
@@ -54,6 +84,8 @@ export default function Page() {
type="authentication"
placeholder="Personal phone"
autoCorrect={false}
value={deviceName}
onChangeText={setDeviceName}
/>
</YStack>
</YStack>
@@ -64,13 +96,22 @@ export default function Page() {
flexDirection="column"
gap="$4"
>
<MWButton type="purple">Login</MWButton>
<MWButton type="purple" onPress={() => mutation.mutate()}>
Login
</MWButton>
{mutation.isError && (
<Text color="$rose200" textAlign="center">
{mutation.error.message}
</Text>
)}
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold">
Don&apos;t have an account yet?{"\n"}
<Link href={{ pathname: "/sync/register" }} asChild>
<Text color="$purple100" fontWeight="$bold">
Create an account.
</Text>
</Link>
</Paragraph>
</MWCard.Footer>
</MWCard>

View File

@@ -1,115 +1,43 @@
import { useState } from "react";
import { Link, Stack } from "expo-router";
import { FontAwesome6, Ionicons } from "@expo/vector-icons";
import { Circle, H4, Label, Paragraph, View, XStack, YStack } from "tamagui";
import { LinearGradient } from "tamagui/linear-gradient";
import { Stack, useRouter } from "expo-router";
import { H4, Label, Paragraph, View, YStack } from "tamagui";
import { Avatar } from "~/components/account/Avatar";
import { ColorPicker, colors } from "~/components/account/ColorPicker";
import {
expoIcons,
expoIconsToDbIcons,
UserIconPicker,
} from "~/components/account/UserIconPicker";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
import { MWInput } from "~/components/ui/Input";
const colors = ["#0A54FF", "#CF2E68", "#F9DD7F", "#7652DD", "#2ECFA8"] as const;
function ColorPicker(props: {
value: (typeof colors)[number];
onInput: (v: (typeof colors)[number]) => void;
}) {
return (
<XStack gap="$2">
{colors.map((color) => {
return (
<View
onPress={() => props.onInput(color)}
flexGrow={1}
height="$4"
borderRadius="$4"
justifyContent="center"
alignItems="center"
backgroundColor={color}
key={color}
>
{props.value === color ? (
<Ionicons name="checkmark-circle" size={24} color="white" />
) : null}
</View>
);
})}
</XStack>
);
}
const icons = [
"user-group",
"couch",
"mobile-screen",
"ticket",
"handcuffs",
] as const;
function UserIconPicker(props: {
value: (typeof icons)[number];
onInput: (v: (typeof icons)[number]) => void;
}) {
return (
<XStack gap="$2">
{icons.map((icon) => {
return (
<View
flexGrow={1}
height="$4"
borderRadius="$4"
justifyContent="center"
alignItems="center"
backgroundColor={props.value === icon ? "$purple400" : "$shade400"}
borderColor={props.value === icon ? "$purple200" : "$shade400"}
borderWidth={1}
key={icon}
onPress={() => props.onInput(icon)}
>
<FontAwesome6 name={icon} size={24} color="white" />
</View>
);
})}
</XStack>
);
}
interface AvatarProps {
colorA: string;
colorB: string;
icon: (typeof icons)[number];
}
export function Avatar(props: AvatarProps) {
return (
<Circle
backgroundColor={props.colorA}
height="$6"
width="$6"
justifyContent="center"
alignItems="center"
>
<LinearGradient
colors={[props.colorA, props.colorB]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
borderRadius="$12"
width="100%"
height="100%"
justifyContent="center"
alignItems="center"
>
<FontAwesome6 name={props.icon} size={24} color="white" />
</LinearGradient>
</Circle>
);
}
export default function Page() {
const [color, setColor] = useState<(typeof colors)[number]>(colors[0]);
const [color2, setColor2] = useState<(typeof colors)[number]>(colors[0]);
const [icon, setIcon] = useState<(typeof icons)[number]>(icons[0]);
const router = useRouter();
const [deviceName, setDeviceName] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [colorA, setColorA] = useState<(typeof colors)[number]>(colors[0]);
const [colorB, setColorB] = useState<(typeof colors)[number]>(colors[0]);
const [icon, setIcon] = useState<(typeof expoIcons)[number]>(expoIcons[0]);
const handleNext = () => {
if (!deviceName) {
setErrorMessage("Please enter a device name");
return;
}
return router.push({
pathname: "/sync/register/confirm",
params: {
deviceName,
colorA,
colorB,
icon: expoIconsToDbIcons[icon],
},
});
};
return (
<ScreenLayout
@@ -129,7 +57,7 @@ export default function Page() {
<MWCard bordered padded>
<MWCard.Header>
<View alignItems="center" marginBottom="$3">
<Avatar colorA={color} colorB={color2} icon={icon} />
<Avatar colorA={colorA} colorB={colorB} icon={icon} />
</View>
<H4 fontWeight="$bold" textAlign="center">
@@ -152,18 +80,19 @@ export default function Page() {
<Label fontWeight="$bold">Device name</Label>
<MWInput
type="authentication"
placeholder="Passphrase"
secureTextEntry
placeholder="Personal phone"
autoCorrect={false}
value={deviceName}
onChangeText={setDeviceName}
/>
</YStack>
<YStack gap="$1">
<Label fontWeight="$bold">Profile color one</Label>
<ColorPicker value={color} onInput={(color) => setColor(color)} />
<ColorPicker value={colorA} onInput={(color) => setColorA(color)} />
</YStack>
<YStack gap="$1">
<Label fontWeight="$bold">Profile color two</Label>
<ColorPicker value={color2} onInput={(color) => setColor2(color)} />
<ColorPicker value={colorB} onInput={(color) => setColorB(color)} />
</YStack>
<YStack gap="$1">
<Label fontWeight="$bold">User icon</Label>
@@ -172,17 +101,14 @@ export default function Page() {
</YStack>
<MWCard.Footer justifyContent="center" flexDirection="column" gap="$4">
<Link
href={{
pathname: "/sync/register/confirm",
}}
replace
asChild
>
<MWButton type="purple" width="100%">
{errorMessage && (
<Paragraph color="$rose200" textAlign="center">
{errorMessage}
</Paragraph>
)}
<MWButton type="purple" width="100%" onPress={handleNext}>
Next
</MWButton>
</Link>
</MWCard.Footer>
</MWCard>
</ScreenLayout>

View File

@@ -1,12 +1,47 @@
import { Link, Stack } from "expo-router";
import { useState } from "react";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useMutation } from "@tanstack/react-query";
import { H4, Label, Paragraph, YStack } from "tamagui";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
import { MWInput } from "~/components/ui/Input";
import { useAuth } from "~/hooks/useAuth";
export default function Page() {
const router = useRouter();
// Requires type casting, typecheck fails for type-safe params
const { deviceName, colorA, colorB, icon } =
useLocalSearchParams() as unknown as {
deviceName: string;
colorA: string;
colorB: string;
icon: string;
};
const { register } = useAuth();
const [passphrase, setPassphrase] = useState("");
const mutation = useMutation({
mutationKey: ["register", deviceName, colorA, colorB, icon],
mutationFn: () =>
register({
// TODO: "Add recaptchaToken",
mnemonic: passphrase,
userData: {
device: deviceName,
profile: { colorA, colorB, icon },
},
}),
onSuccess: (data) => {
if (data) {
return router.push("/(tabs)/movie-web");
}
return null;
},
});
return (
<ScreenLayout
showHeader={false}
@@ -47,20 +82,22 @@ export default function Page() {
placeholder="Passphrase"
secureTextEntry
autoCorrect={false}
value={passphrase}
onChangeText={setPassphrase}
/>
</YStack>
</YStack>
<MWCard.Footer justifyContent="center" flexDirection="column" gap="$4">
<Link
href={{
pathname: "/(tabs)/movie-web",
}}
replace
asChild
>
<MWButton type="purple">Create account</MWButton>
</Link>
{mutation.isError && (
<Paragraph color="$rose200" textAlign="center">
{mutation.error.message}
</Paragraph>
)}
<MWButton type="purple" onPress={() => mutation.mutate()}>
Create account
</MWButton>
</MWCard.Footer>
</MWCard>
</ScreenLayout>

View File

@@ -28,7 +28,7 @@ function PassphraseWord({ word }: { word: string }) {
export default function Page() {
const theme = useTheme();
const words = genMnemonic().split(" ");
const words = genMnemonic();
return (
<ScreenLayout
@@ -86,7 +86,7 @@ export default function Page() {
gap: 8,
}}
onPress={async () => {
await Clipboard.setStringAsync(words.join(""));
await Clipboard.setStringAsync(words);
}}
>
<Feather name="copy" size={18} color={theme.shade200.val} />
@@ -103,7 +103,7 @@ export default function Page() {
justifyContent="center"
padding="$3"
>
{words.map((word, index) => (
{words.split(" ").map((word, index) => (
<PassphraseWord key={index} word={word} />
))}
</View>
@@ -122,7 +122,13 @@ export default function Page() {
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold">
Already have an account?{"\n"}
<Text color="$purple100" fontWeight="$bold">
<Link href="/sync/login">Login here</Link>
<Link
href={{
pathname: "/sync/login",
}}
>
Login here
</Link>
</Text>
</Paragraph>
</MWCard.Footer>

View File

@@ -7,13 +7,16 @@ import { getBackendMeta } from "@movie-web/api";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { MWButton } from "~/components/ui/Button";
import { MWCard } from "~/components/ui/Card";
import { useAuthStore } from "~/stores/settings";
export default function Page() {
const { url } = useLocalSearchParams<{ url: string }>();
const { backendUrl } = useLocalSearchParams<{ backendUrl: string }>();
const setBackendUrl = useAuthStore((state) => state.setBackendUrl);
const meta = useQuery({
queryKey: ["backendMeta", url],
queryFn: () => getBackendMeta(url as unknown as string),
queryKey: ["backendMeta", backendUrl],
queryFn: () => getBackendMeta(backendUrl as unknown as string),
});
return (
@@ -52,7 +55,7 @@ export default function Page() {
color="white"
textDecorationLine="underline"
>
{url}
{backendUrl}
</Text>
. Please confirm you trust it before making an account.
</>
@@ -95,8 +98,13 @@ export default function Page() {
pathname: "/sync/register",
}}
asChild
onPress={() => {
setBackendUrl(backendUrl as unknown as string);
}}
>
<MWButton type="purple">I trust this server</MWButton>
<MWButton type="purple" disabled={!meta.isSuccess}>
I trust this server
</MWButton>
</Link>
<Link
href={{

View File

@@ -0,0 +1,165 @@
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { H3, Spinner, Text, XStack, YStack } from "tamagui";
import {
base64ToBuffer,
decryptData,
getSessions,
removeSession,
} from "@movie-web/api";
import { useAuth } from "~/hooks/useAuth";
import { useAuthStore } from "~/stores/settings";
import ScreenLayout from "../layout/ScreenLayout";
import { MWButton } from "../ui/Button";
import { MWCard } from "../ui/Card";
import { MWInput } from "../ui/Input";
import { MWSeparator } from "../ui/Separator";
import { Avatar } from "./Avatar";
import { DeleteAccountAlert } from "./DeleteAccountAlert";
import { getExpoIconFromDbIcon } from "./UserIconPicker";
export function AccountInformation() {
const account = useAuthStore((state) => state.account);
const backendUrl = useAuthStore((state) => state.backendUrl);
const queryClient = useQueryClient();
const { decryptedName, logout } = useAuth();
const logoutMutation = useMutation({
mutationKey: ["logout"],
mutationFn: logout,
});
const removeSessionMutation = useMutation({
mutationKey: ["removeSession"],
mutationFn: (sessionId: string) =>
removeSession(backendUrl, account!.token, sessionId),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ["sessions", backendUrl, account],
});
},
});
const sessions = useQuery({
queryKey: ["sessions", backendUrl, account],
queryFn: () => getSessions(backendUrl, account!),
enabled: !!account,
});
const deviceListSorted = useMemo(() => {
let list =
sessions.data?.map((session) => {
const decryptedName = decryptData(
session.device,
base64ToBuffer(account!.seed),
);
return {
current: session.id === account!.sessionId,
id: session.id,
name: decryptedName,
};
}) ?? [];
list = list.sort((a, b) => {
if (a.current) return -1;
if (b.current) return 1;
return a.name.localeCompare(b.name);
});
return list;
}, [sessions.data, account]);
if (!account) return null;
return (
<ScreenLayout>
<YStack gap="$6">
<YStack gap="$4">
<Text fontSize="$7" fontWeight="$bold">
Account
</Text>
<MWSeparator />
<MWCard bordered padded>
<XStack gap="$4" alignItems="center">
<Avatar
{...account.profile}
icon={getExpoIconFromDbIcon(account.profile.icon)}
width="$7"
height="$7"
/>
<YStack gap="$4">
<Text fontWeight="$bold">Device name</Text>
<MWInput
type="authentication"
value={decryptedName}
minWidth={"80%"}
maxWidth={"80%"}
/>
<MWButton
type="danger"
width={"50%"}
onPress={() => logoutMutation.mutate()}
>
Logout
</MWButton>
</YStack>
</XStack>
</MWCard>
</YStack>
<YStack gap="$4">
<Text fontSize="$7" fontWeight="$bold">
Devices
</Text>
<MWSeparator />
{sessions.isLoading && <Spinner />}
{sessions.isError && (
<Text fontWeight="$bold" color="$rose200">
Error loading sessions
</Text>
)}
{deviceListSorted.map((device) => (
<MWCard bordered padded key={device.id}>
<XStack gap="$4" alignItems="center">
<YStack gap="$1" flexGrow={1}>
<Text fontWeight="$semibold" color="$ash300">
Device name
</Text>
<Text fontWeight="$bold">{device.name}</Text>
</YStack>
{!device.current && (
<MWButton
type="danger"
onPress={() => removeSessionMutation.mutate(device.id)}
>
Remove
</MWButton>
)}
</XStack>
</MWCard>
))}
</YStack>
<YStack gap="$4">
<Text fontSize="$7" fontWeight="$bold">
Actions
</Text>
<MWSeparator />
<MWCard bordered padded>
<YStack gap="$3">
<H3 fontWeight="$bold">Delete account</H3>
<Text color="$ash300" fontWeight="$semibold">
This action is irreversible. All data will be deleted and
nothing can be recovered.
</Text>
<DeleteAccountAlert />
</YStack>
</MWCard>
</YStack>
</YStack>
</ScreenLayout>
);
}

View File

@@ -0,0 +1,38 @@
import type { CircleProps } from "tamagui";
import { FontAwesome6 } from "@expo/vector-icons";
import { Circle } from "tamagui";
import { LinearGradient } from "tamagui/linear-gradient";
import type { expoIcons } from "./UserIconPicker";
export interface AvatarProps {
colorA: string;
colorB: string;
icon: (typeof expoIcons)[number];
}
export function Avatar(props: AvatarProps & CircleProps) {
return (
<Circle
backgroundColor={props.colorA}
height="$6"
width="$6"
justifyContent="center"
alignItems="center"
{...props}
>
<LinearGradient
colors={[props.colorA, props.colorB]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
borderRadius="$12"
width="100%"
height="100%"
justifyContent="center"
alignItems="center"
>
<FontAwesome6 name={props.icon} size={24} color="white" />
</LinearGradient>
</Circle>
);
}

View File

@@ -0,0 +1,39 @@
import React from "react";
import { Ionicons } from "@expo/vector-icons";
import { View, XStack } from "tamagui";
export const colors = [
"#0A54FF",
"#CF2E68",
"#F9DD7F",
"#7652DD",
"#2ECFA8",
] as const;
export function ColorPicker(props: {
value: (typeof colors)[number];
onInput: (v: (typeof colors)[number]) => void;
}) {
return (
<XStack gap="$2">
{colors.map((color) => {
return (
<View
onPress={() => props.onInput(color)}
flexGrow={1}
height="$4"
borderRadius="$4"
justifyContent="center"
alignItems="center"
backgroundColor={color}
key={color}
>
{props.value === color ? (
<Ionicons name="checkmark-circle" size={24} color="white" />
) : null}
</View>
);
})}
</XStack>
);
}

View File

@@ -0,0 +1,86 @@
import { useMutation } from "@tanstack/react-query";
import { AlertDialog, XStack, YStack } from "tamagui";
import { deleteUser } from "@movie-web/api";
import { useAuth } from "~/hooks/useAuth";
import { useAuthStore } from "~/stores/settings";
import { MWButton } from "../ui/Button";
export function DeleteAccountAlert() {
const account = useAuthStore((state) => state.account);
const backendUrl = useAuthStore((state) => state.backendUrl);
const { logout } = useAuth();
const logoutMutation = useMutation({
mutationKey: ["logout"],
mutationFn: logout,
});
const deleteAccountMutation = useMutation({
mutationKey: ["deleteAccount"],
mutationFn: () => deleteUser(backendUrl, account!),
onSuccess: () => {
logoutMutation.mutate();
},
});
return (
<AlertDialog native>
<AlertDialog.Trigger asChild>
<MWButton type="danger" width="$14" alignSelf="flex-end">
Delete account
</MWButton>
</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay
key="overlay"
animation="quick"
opacity={0.5}
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
<AlertDialog.Content
bordered
elevate
key="content"
animation={[
"quick",
{
opacity: {
overshootClamping: true,
},
},
]}
enterStyle={{ x: 0, y: -20, opacity: 0, scale: 0.9 }}
exitStyle={{ x: 0, y: 10, opacity: 0, scale: 0.95 }}
x={0}
scale={1}
opacity={1}
y={0}
>
<YStack gap="$4">
<AlertDialog.Title>Are you sure?</AlertDialog.Title>
<AlertDialog.Description>
This action is irreversible. All data will be deleted and nothing
can be recovered.
</AlertDialog.Description>
<XStack gap="$3" justifyContent="flex-end">
<AlertDialog.Cancel asChild>
<MWButton>Cancel</MWButton>
</AlertDialog.Cancel>
<AlertDialog.Action
asChild
onPress={() => deleteAccountMutation.mutate()}
>
<MWButton type="purple">I am sure</MWButton>
</AlertDialog.Action>
</XStack>
</YStack>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog>
);
}

View File

@@ -0,0 +1,60 @@
import { Link } from "expo-router";
import { H3, H5, Paragraph, View } from "tamagui";
import { useAuthStore } from "~/stores/settings";
import ScreenLayout from "../layout/ScreenLayout";
import { MWButton } from "../ui/Button";
import { MWCard } from "../ui/Card";
import { MWInput } from "../ui/Input";
export function AccountGetStarted() {
const { backendUrl, setBackendUrl } = useAuthStore();
return (
<ScreenLayout
contentContainerStyle={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<MWCard bordered padded>
<MWCard.Header>
<H3 fontWeight="$bold" paddingBottom="$1">
Sync to the cloud
</H3>
<H5 color="$shade200" fontWeight="$semibold" paddingVertical="$3">
Share your watch progress between devices and keep them synced.
</H5>
<Paragraph color="$shade200">
First choose the backend you want to use. If you do not know what
this does, use the default and click on &apos;Get started&apos;.
</Paragraph>
</MWCard.Header>
<View padding="$4">
<MWInput
placeholder={backendUrl}
type="authentication"
value={backendUrl}
onChangeText={setBackendUrl}
/>
</View>
<MWCard.Footer padded justifyContent="center">
<Link
href={{
pathname: "/sync/trust/[backendUrl]",
params: { backendUrl },
}}
asChild
>
<MWButton type="purple" width="100%">
Get started
</MWButton>
</Link>
</MWCard.Footer>
</MWCard>
</ScreenLayout>
);
}

View File

@@ -0,0 +1,53 @@
import React from "react";
import { FontAwesome6 } from "@expo/vector-icons";
import { View, XStack } from "tamagui";
export const expoIcons = [
"user-group",
"couch",
"mobile-screen",
"ticket",
"handcuffs",
] as const;
export const expoIconsToDbIcons: Record<(typeof expoIcons)[number], string> = {
"user-group": "userGroup",
couch: "couch",
"mobile-screen": "mobile",
ticket: "ticket",
handcuffs: "handcuffs",
};
export const getExpoIconFromDbIcon = (icon: string) => {
return Object.keys(expoIconsToDbIcons).find(
(key) => expoIconsToDbIcons[key as (typeof expoIcons)[number]] === icon,
) as (typeof expoIcons)[number];
};
export function UserIconPicker(props: {
value: (typeof expoIcons)[number];
onInput: (v: (typeof expoIcons)[number]) => void;
}) {
return (
<XStack gap="$2">
{expoIcons.map((icon) => {
return (
<View
flexGrow={1}
height="$4"
borderRadius="$4"
justifyContent="center"
alignItems="center"
backgroundColor={props.value === icon ? "$purple400" : "$shade400"}
borderColor={props.value === icon ? "$purple200" : "$shade400"}
borderWidth={1}
key={icon}
onPress={() => props.onInput(icon)}
>
<FontAwesome6 name={icon} size={24} color="white" />
</View>
);
})}
</XStack>
);
}

View File

@@ -10,6 +10,11 @@ export const MWButton = styled(Button, {
pressStyle: {
backgroundColor: "$silver100",
},
disabledStyle: {
backgroundColor: "$ash500",
color: "$ash200",
pointerEvents: "none",
},
},
secondary: {
backgroundColor: "$ash700",
@@ -18,6 +23,11 @@ export const MWButton = styled(Button, {
pressStyle: {
backgroundColor: "$ash500",
},
disabledStyle: {
backgroundColor: "$ash900",
color: "$ash200",
pointerEvents: "none",
},
},
purple: {
backgroundColor: "$purple500",
@@ -26,6 +36,11 @@ export const MWButton = styled(Button, {
pressStyle: {
backgroundColor: "$purple400",
},
disabledStyle: {
backgroundColor: "$purple700",
color: "$ash200",
pointerEvents: "none",
},
},
cancel: {
backgroundColor: "$ash500",
@@ -34,6 +49,24 @@ export const MWButton = styled(Button, {
pressStyle: {
backgroundColor: "$ash300",
},
disabledStyle: {
backgroundColor: "$ash700",
color: "$ash200",
pointerEvents: "none",
},
},
danger: {
backgroundColor: "$rose300",
color: "white",
fontWeight: "bold",
pressStyle: {
backgroundColor: "$rose200",
},
disabledStyle: {
backgroundColor: "$rose500",
color: "$ash200",
pointerEvents: "none",
},
},
},
} as const,

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import type {
AccountWithToken,
@@ -8,9 +8,11 @@ import type {
UserResponse,
} from "@movie-web/api";
import {
base64ToBuffer,
bookmarkMediaToInput,
bytesToBase64,
bytesToBase64Url,
decryptData,
encryptData,
getBookmarks,
getLoginChallengeToken,
@@ -192,9 +194,18 @@ export function useAuth() {
[backendUrl, syncData, logout],
);
const decryptedName = useMemo(() => {
if (!currentAccount) return "";
return decryptData(
currentAccount.deviceName,
base64ToBuffer(currentAccount.seed),
);
}, [currentAccount]);
return {
loggedIn,
profile,
decryptedName,
login,
logout,
register,

View File

@@ -38,7 +38,10 @@ export async function f<T>(url: string, ops?: FetcherOptions): Promise<T> {
const fullUrl = makeFullUrl(url, ops);
const response = await fetch(fullUrl, {
method: ops?.method ?? "GET",
headers: ops?.headers,
headers: {
"Content-Type": "application/json",
...ops?.headers,
},
body: ops?.body ? JSON.stringify(ops.body) : undefined,
});