mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 12:33:26 +00:00
Compare commits
6 Commits
a981beae11
...
4ae5c3bc9b
Author | SHA1 | Date | |
---|---|---|---|
|
4ae5c3bc9b | ||
|
6c189d504b | ||
|
118fa2092d | ||
|
5ef90f52a3 | ||
|
d7940766e7 | ||
|
e3d507db72 |
@@ -2,3 +2,6 @@ import "expo-router/entry";
|
|||||||
import "react-native-gesture-handler";
|
import "react-native-gesture-handler";
|
||||||
import "@react-native-anywhere/polyfill-base64";
|
import "@react-native-anywhere/polyfill-base64";
|
||||||
import "text-encoding-polyfill";
|
import "text-encoding-polyfill";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
import crypto from "react-native-quick-crypto";
|
||||||
|
@@ -96,7 +96,11 @@ export default function Page() {
|
|||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap="$4"
|
gap="$4"
|
||||||
>
|
>
|
||||||
<MWButton type="purple" onPress={() => mutation.mutate()}>
|
<MWButton
|
||||||
|
type="purple"
|
||||||
|
onPress={() => mutation.mutate()}
|
||||||
|
isLoading={mutation.isPending}
|
||||||
|
>
|
||||||
Login
|
Login
|
||||||
</MWButton>
|
</MWButton>
|
||||||
{mutation.isError && (
|
{mutation.isError && (
|
||||||
|
@@ -6,7 +6,7 @@ import { Avatar } from "~/components/account/Avatar";
|
|||||||
import { ColorPicker, colors } from "~/components/account/ColorPicker";
|
import { ColorPicker, colors } from "~/components/account/ColorPicker";
|
||||||
import {
|
import {
|
||||||
expoIcons,
|
expoIcons,
|
||||||
expoIconsToDbIcons,
|
getDbIconFromExpoIcon,
|
||||||
UserIconPicker,
|
UserIconPicker,
|
||||||
} from "~/components/account/UserIconPicker";
|
} from "~/components/account/UserIconPicker";
|
||||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||||
@@ -19,9 +19,9 @@ export default function Page() {
|
|||||||
|
|
||||||
const [deviceName, setDeviceName] = useState("");
|
const [deviceName, setDeviceName] = useState("");
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [colorA, setColorA] = useState<(typeof colors)[number]>(colors[0]);
|
const [colorA, setColorA] = useState<string>(colors[0]);
|
||||||
const [colorB, setColorB] = useState<(typeof colors)[number]>(colors[0]);
|
const [colorB, setColorB] = useState<string>(colors[0]);
|
||||||
const [icon, setIcon] = useState<(typeof expoIcons)[number]>(expoIcons[0]);
|
const [icon, setIcon] = useState<string>(expoIcons[0]);
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
if (!deviceName) {
|
if (!deviceName) {
|
||||||
@@ -34,7 +34,7 @@ export default function Page() {
|
|||||||
deviceName,
|
deviceName,
|
||||||
colorA,
|
colorA,
|
||||||
colorB,
|
colorB,
|
||||||
icon: expoIconsToDbIcons[icon],
|
icon: getDbIconFromExpoIcon(icon),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@@ -95,7 +95,11 @@ export default function Page() {
|
|||||||
</Paragraph>
|
</Paragraph>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MWButton type="purple" onPress={() => mutation.mutate()}>
|
<MWButton
|
||||||
|
type="purple"
|
||||||
|
onPress={() => mutation.mutate()}
|
||||||
|
isLoading={mutation.isPending}
|
||||||
|
>
|
||||||
Create account
|
Create account
|
||||||
</MWButton>
|
</MWButton>
|
||||||
</MWCard.Footer>
|
</MWCard.Footer>
|
||||||
|
@@ -1,15 +1,21 @@
|
|||||||
import { useMemo } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { H3, Spinner, Text, XStack, YStack } from "tamagui";
|
import { H5, Spinner, Text, View, XStack, YStack } from "tamagui";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
base64ToBuffer,
|
base64ToBuffer,
|
||||||
decryptData,
|
decryptData,
|
||||||
|
editUser,
|
||||||
|
encryptData,
|
||||||
getSessions,
|
getSessions,
|
||||||
removeSession,
|
removeSession,
|
||||||
|
updateSession,
|
||||||
|
updateSettings,
|
||||||
} from "@movie-web/api";
|
} from "@movie-web/api";
|
||||||
|
|
||||||
import { useAuth } from "~/hooks/useAuth";
|
import { useAuth } from "~/hooks/useAuth";
|
||||||
|
import { useSettingsState } from "~/hooks/useSettingsState";
|
||||||
import { useAuthStore } from "~/stores/settings";
|
import { useAuthStore } from "~/stores/settings";
|
||||||
import ScreenLayout from "../layout/ScreenLayout";
|
import ScreenLayout from "../layout/ScreenLayout";
|
||||||
import { MWButton } from "../ui/Button";
|
import { MWButton } from "../ui/Button";
|
||||||
@@ -17,12 +23,20 @@ import { MWCard } from "../ui/Card";
|
|||||||
import { MWInput } from "../ui/Input";
|
import { MWInput } from "../ui/Input";
|
||||||
import { MWSeparator } from "../ui/Separator";
|
import { MWSeparator } from "../ui/Separator";
|
||||||
import { Avatar } from "./Avatar";
|
import { Avatar } from "./Avatar";
|
||||||
|
import { ChangeProfileModal } from "./ChangeProfileModal";
|
||||||
import { DeleteAccountAlert } from "./DeleteAccountAlert";
|
import { DeleteAccountAlert } from "./DeleteAccountAlert";
|
||||||
import { getExpoIconFromDbIcon } from "./UserIconPicker";
|
import { getDbIconFromExpoIcon, getExpoIconFromDbIcon } from "./UserIconPicker";
|
||||||
|
|
||||||
export function AccountInformation() {
|
export function AccountInformation() {
|
||||||
const account = useAuthStore((state) => state.account);
|
const account = useAuthStore((state) => state.account);
|
||||||
const backendUrl = useAuthStore((state) => state.backendUrl);
|
const backendUrl = useAuthStore((state) => state.backendUrl);
|
||||||
|
const proxySet = useAuthStore((s) => s.proxySet);
|
||||||
|
const setProxySet = useAuthStore((s) => s.setProxySet);
|
||||||
|
const updateProfile = useAuthStore((s) => s.setAccountProfile);
|
||||||
|
const updateDeviceName = useAuthStore((s) => s.updateDeviceName);
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { decryptedName, logout } = useAuth();
|
const { decryptedName, logout } = useAuth();
|
||||||
@@ -50,14 +64,15 @@ export function AccountInformation() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deviceListSorted = useMemo(() => {
|
const deviceListSorted = useMemo(() => {
|
||||||
|
if (!sessions.data || !account) return [];
|
||||||
let list =
|
let list =
|
||||||
sessions.data?.map((session) => {
|
sessions.data?.map((session) => {
|
||||||
const decryptedName = decryptData(
|
const decryptedName = decryptData(
|
||||||
session.device,
|
session.device,
|
||||||
base64ToBuffer(account!.seed),
|
base64ToBuffer(account.seed),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
current: session.id === account!.sessionId,
|
current: session.id === account.sessionId,
|
||||||
id: session.id,
|
id: session.id,
|
||||||
name: decryptedName,
|
name: decryptedName,
|
||||||
};
|
};
|
||||||
@@ -70,9 +85,60 @@ export function AccountInformation() {
|
|||||||
return list;
|
return list;
|
||||||
}, [sessions.data, account]);
|
}, [sessions.data, account]);
|
||||||
|
|
||||||
|
const state = useSettingsState(decryptedName, proxySet, account?.profile);
|
||||||
|
|
||||||
|
const saveChanges = useCallback(async () => {
|
||||||
|
if (account && backendUrl) {
|
||||||
|
if (state.proxyUrls.changed) {
|
||||||
|
await updateSettings(backendUrl, account, {
|
||||||
|
proxyUrls: state.proxyUrls.state?.filter((v) => v !== "") ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (state.deviceName.changed) {
|
||||||
|
const newDeviceName = await encryptData(
|
||||||
|
state.deviceName.state,
|
||||||
|
base64ToBuffer(account.seed),
|
||||||
|
);
|
||||||
|
await updateSession(backendUrl, account, {
|
||||||
|
deviceName: newDeviceName,
|
||||||
|
});
|
||||||
|
updateDeviceName(newDeviceName);
|
||||||
|
}
|
||||||
|
if (state.profile.changed) {
|
||||||
|
await editUser(backendUrl, account, {
|
||||||
|
profile: state.profile.state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProxySet(state.proxyUrls.state?.filter((v) => v !== "") ?? null);
|
||||||
|
|
||||||
|
if (state.profile.state) {
|
||||||
|
updateProfile(state.profile.state);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
account,
|
||||||
|
backendUrl,
|
||||||
|
setProxySet,
|
||||||
|
state.deviceName.changed,
|
||||||
|
state.deviceName.state,
|
||||||
|
state.profile.changed,
|
||||||
|
state.profile.state,
|
||||||
|
state.proxyUrls.changed,
|
||||||
|
state.proxyUrls.state,
|
||||||
|
updateDeviceName,
|
||||||
|
updateProfile,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const saveChangesMutation = useMutation({
|
||||||
|
mutationKey: ["saveChanges"],
|
||||||
|
mutationFn: saveChanges,
|
||||||
|
});
|
||||||
|
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<ScreenLayout>
|
<ScreenLayout>
|
||||||
<YStack gap="$6">
|
<YStack gap="$6">
|
||||||
<YStack gap="$4">
|
<YStack gap="$4">
|
||||||
@@ -81,33 +147,76 @@ export function AccountInformation() {
|
|||||||
</Text>
|
</Text>
|
||||||
<MWSeparator />
|
<MWSeparator />
|
||||||
|
|
||||||
|
{state.profile.state && (
|
||||||
<MWCard bordered padded>
|
<MWCard bordered padded>
|
||||||
<XStack gap="$4" alignItems="center">
|
<XStack gap="$4" alignItems="center">
|
||||||
|
<ChangeProfileModal
|
||||||
|
colorA={state.profile.state.colorA}
|
||||||
|
setColorA={(v) => {
|
||||||
|
state.profile.set((s) =>
|
||||||
|
s ? { ...s, colorA: v } : undefined,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
colorB={state.profile.state.colorB}
|
||||||
|
setColorB={(v) =>
|
||||||
|
state.profile.set((s) =>
|
||||||
|
s ? { ...s, colorB: v } : undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
icon={state.profile.state.icon}
|
||||||
|
setUserIcon={(v) =>
|
||||||
|
state.profile.set((s) =>
|
||||||
|
s
|
||||||
|
? { ...s, icon: getDbIconFromExpoIcon(v) }
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
/>
|
||||||
<Avatar
|
<Avatar
|
||||||
{...account.profile}
|
{...state.profile.state}
|
||||||
icon={getExpoIconFromDbIcon(account.profile.icon)}
|
icon={getExpoIconFromDbIcon(state.profile.state.icon)}
|
||||||
width="$7"
|
width="$7"
|
||||||
height="$7"
|
height="$7"
|
||||||
|
bottomItem={
|
||||||
|
<XStack
|
||||||
|
backgroundColor="$shade200"
|
||||||
|
px="$2"
|
||||||
|
py="$1"
|
||||||
|
borderRadius="$4"
|
||||||
|
gap="$1.5"
|
||||||
|
alignItems="center"
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="edit" size={10} color="white" />
|
||||||
|
<Text fontSize="$2">Edit</Text>
|
||||||
|
</XStack>
|
||||||
|
}
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
/>
|
/>
|
||||||
<YStack gap="$4">
|
<YStack gap="$4">
|
||||||
<Text fontWeight="$bold">Device name</Text>
|
<Text fontWeight="$bold">Device name</Text>
|
||||||
<MWInput
|
<MWInput
|
||||||
type="authentication"
|
type="authentication"
|
||||||
value={decryptedName}
|
value={state.deviceName.state}
|
||||||
minWidth={"80%"}
|
onChangeText={state.deviceName.set}
|
||||||
maxWidth={"80%"}
|
alignSelf="flex-start"
|
||||||
|
width="$14"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MWButton
|
<MWButton
|
||||||
type="danger"
|
type="danger"
|
||||||
width={"50%"}
|
|
||||||
onPress={() => logoutMutation.mutate()}
|
onPress={() => logoutMutation.mutate()}
|
||||||
|
alignSelf="flex-start"
|
||||||
|
isLoading={logoutMutation.isPending}
|
||||||
>
|
>
|
||||||
Logout
|
Logout
|
||||||
</MWButton>
|
</MWButton>
|
||||||
</YStack>
|
</YStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
</MWCard>
|
</MWCard>
|
||||||
|
)}
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<YStack gap="$4">
|
<YStack gap="$4">
|
||||||
@@ -133,6 +242,7 @@ export function AccountInformation() {
|
|||||||
{!device.current && (
|
{!device.current && (
|
||||||
<MWButton
|
<MWButton
|
||||||
type="danger"
|
type="danger"
|
||||||
|
isLoading={removeSessionMutation.isPending}
|
||||||
onPress={() => removeSessionMutation.mutate(device.id)}
|
onPress={() => removeSessionMutation.mutate(device.id)}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
@@ -150,7 +260,7 @@ export function AccountInformation() {
|
|||||||
<MWSeparator />
|
<MWSeparator />
|
||||||
<MWCard bordered padded>
|
<MWCard bordered padded>
|
||||||
<YStack gap="$3">
|
<YStack gap="$3">
|
||||||
<H3 fontWeight="$bold">Delete account</H3>
|
<H5 fontWeight="$bold">Delete account</H5>
|
||||||
<Text color="$ash300" fontWeight="$semibold">
|
<Text color="$ash300" fontWeight="$semibold">
|
||||||
This action is irreversible. All data will be deleted and
|
This action is irreversible. All data will be deleted and
|
||||||
nothing can be recovered.
|
nothing can be recovered.
|
||||||
@@ -161,5 +271,36 @@ export function AccountInformation() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
</ScreenLayout>
|
</ScreenLayout>
|
||||||
|
|
||||||
|
{state.changed && (
|
||||||
|
<View position="absolute" alignItems="center" bottom="$2" px="$2">
|
||||||
|
<XStack
|
||||||
|
width="100%"
|
||||||
|
padding="$4"
|
||||||
|
backgroundColor="$shade800"
|
||||||
|
justifyContent="space-between"
|
||||||
|
borderRadius="$4"
|
||||||
|
animation="bounce"
|
||||||
|
enterStyle={{
|
||||||
|
y: 10,
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
opacity={1}
|
||||||
|
scale={1}
|
||||||
|
>
|
||||||
|
<MWButton type="cancel" onPress={state.reset}>
|
||||||
|
Reset
|
||||||
|
</MWButton>
|
||||||
|
<MWButton
|
||||||
|
type="purple"
|
||||||
|
onPress={() => saveChangesMutation.mutate()}
|
||||||
|
isLoading={saveChangesMutation.isPending}
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</MWButton>
|
||||||
|
</XStack>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,14 +1,13 @@
|
|||||||
import type { CircleProps } from "tamagui";
|
import type { CircleProps } from "tamagui";
|
||||||
import { FontAwesome6 } from "@expo/vector-icons";
|
import { FontAwesome6 } from "@expo/vector-icons";
|
||||||
import { Circle } from "tamagui";
|
import { Circle, View } from "tamagui";
|
||||||
import { LinearGradient } from "tamagui/linear-gradient";
|
import { LinearGradient } from "tamagui/linear-gradient";
|
||||||
|
|
||||||
import type { expoIcons } from "./UserIconPicker";
|
|
||||||
|
|
||||||
export interface AvatarProps {
|
export interface AvatarProps {
|
||||||
colorA: string;
|
colorA: string;
|
||||||
colorB: string;
|
colorB: string;
|
||||||
icon: (typeof expoIcons)[number];
|
icon: string;
|
||||||
|
bottomItem?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Avatar(props: AvatarProps & CircleProps) {
|
export function Avatar(props: AvatarProps & CircleProps) {
|
||||||
@@ -33,6 +32,9 @@ export function Avatar(props: AvatarProps & CircleProps) {
|
|||||||
>
|
>
|
||||||
<FontAwesome6 name={props.icon} size={24} color="white" />
|
<FontAwesome6 name={props.icon} size={24} color="white" />
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
|
<View position="absolute" bottom={0}>
|
||||||
|
{props.bottomItem}
|
||||||
|
</View>
|
||||||
</Circle>
|
</Circle>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
81
apps/expo/src/components/account/ChangeProfileModal.tsx
Normal file
81
apps/expo/src/components/account/ChangeProfileModal.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { H3, Sheet, Text, XStack, YStack } from "tamagui";
|
||||||
|
|
||||||
|
import { MWButton } from "../ui/Button";
|
||||||
|
import { Avatar } from "./Avatar";
|
||||||
|
import { ColorPicker } from "./ColorPicker";
|
||||||
|
import { UserIconPicker } from "./UserIconPicker";
|
||||||
|
|
||||||
|
export function ChangeProfileModal(props: {
|
||||||
|
colorA: string;
|
||||||
|
setColorA: (s: string) => void;
|
||||||
|
colorB: string;
|
||||||
|
setColorB: (s: string) => void;
|
||||||
|
icon: string;
|
||||||
|
setUserIcon: (s: string) => void;
|
||||||
|
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (b: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Sheet
|
||||||
|
forceRemoveScrollEnabled={props.open}
|
||||||
|
modal
|
||||||
|
open={props.open}
|
||||||
|
onOpenChange={props.setOpen}
|
||||||
|
dismissOnSnapToBottom
|
||||||
|
dismissOnOverlayPress
|
||||||
|
animation="fast"
|
||||||
|
snapPoints={[65]}
|
||||||
|
>
|
||||||
|
<Sheet.Handle backgroundColor="$shade100" />
|
||||||
|
<Sheet.Frame
|
||||||
|
backgroundColor="$shade800"
|
||||||
|
padding="$4"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
>
|
||||||
|
<YStack padding="$4" gap="$4">
|
||||||
|
<XStack gap="$4" alignItems="center">
|
||||||
|
<H3 flexGrow={1} fontWeight="$bold">
|
||||||
|
Edit profile picture
|
||||||
|
</H3>
|
||||||
|
<Avatar
|
||||||
|
colorA={props.colorA}
|
||||||
|
colorB={props.colorB}
|
||||||
|
icon={props.icon}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<YStack gap="$2">
|
||||||
|
<Text fontWeight="$bold">Profile color one</Text>
|
||||||
|
<ColorPicker value={props.colorA} onInput={props.setColorA} />
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
<YStack gap="$2">
|
||||||
|
<Text fontWeight="$bold">Profile color two</Text>
|
||||||
|
<ColorPicker value={props.colorB} onInput={props.setColorB} />
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
<YStack gap="$2">
|
||||||
|
<Text fontWeight="$bold">User icon</Text>
|
||||||
|
<UserIconPicker value={props.icon} onInput={props.setUserIcon} />
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
<MWButton
|
||||||
|
type="purple"
|
||||||
|
width="100%"
|
||||||
|
onPress={() => props.setOpen(false)}
|
||||||
|
>
|
||||||
|
Finish editing
|
||||||
|
</MWButton>
|
||||||
|
</Sheet.Frame>
|
||||||
|
<Sheet.Overlay
|
||||||
|
animation="lazy"
|
||||||
|
backgroundColor="rgba(0, 0, 0, 0.8)"
|
||||||
|
enterStyle={{ opacity: 0 }}
|
||||||
|
exitStyle={{ opacity: 0 }}
|
||||||
|
/>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
@@ -11,8 +11,8 @@ export const colors = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function ColorPicker(props: {
|
export function ColorPicker(props: {
|
||||||
value: (typeof colors)[number];
|
value: string;
|
||||||
onInput: (v: (typeof colors)[number]) => void;
|
onInput: (v: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<XStack gap="$2">
|
<XStack gap="$2">
|
||||||
|
@@ -75,7 +75,12 @@ export function DeleteAccountAlert() {
|
|||||||
asChild
|
asChild
|
||||||
onPress={() => deleteAccountMutation.mutate()}
|
onPress={() => deleteAccountMutation.mutate()}
|
||||||
>
|
>
|
||||||
<MWButton type="purple">I am sure</MWButton>
|
<MWButton
|
||||||
|
type="purple"
|
||||||
|
isLoading={deleteAccountMutation.isPending}
|
||||||
|
>
|
||||||
|
I am sure
|
||||||
|
</MWButton>
|
||||||
</AlertDialog.Action>
|
</AlertDialog.Action>
|
||||||
</XStack>
|
</XStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
@@ -24,9 +24,13 @@ export const getExpoIconFromDbIcon = (icon: string) => {
|
|||||||
) as (typeof expoIcons)[number];
|
) as (typeof expoIcons)[number];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getDbIconFromExpoIcon = (icon: string) => {
|
||||||
|
return expoIconsToDbIcons[icon as (typeof expoIcons)[number]];
|
||||||
|
};
|
||||||
|
|
||||||
export function UserIconPicker(props: {
|
export function UserIconPicker(props: {
|
||||||
value: (typeof expoIcons)[number];
|
value: string;
|
||||||
onInput: (v: (typeof expoIcons)[number]) => void;
|
onInput: (v: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<XStack gap="$2">
|
<XStack gap="$2">
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { Button, styled } from "tamagui";
|
import { Button, Spinner, styled, withStaticProperties } from "tamagui";
|
||||||
|
|
||||||
export const MWButton = styled(Button, {
|
const MWButtonFrame = styled(Button, {
|
||||||
variants: {
|
variants: {
|
||||||
type: {
|
type: {
|
||||||
primary: {
|
primary: {
|
||||||
@@ -71,3 +71,22 @@ export const MWButton = styled(Button, {
|
|||||||
},
|
},
|
||||||
} as const,
|
} as const,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ButtonComponent = MWButtonFrame.styleable<{
|
||||||
|
isLoading?: boolean;
|
||||||
|
}>(function Button(props, ref) {
|
||||||
|
const spinnerColor =
|
||||||
|
// @ts-expect-error this is a hack to get the color from the variant
|
||||||
|
MWButtonFrame.staticConfig.variants?.type?.[props.type!]?.color as string;
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
<MWButtonFrame {...props} ref={ref}>
|
||||||
|
{props.isLoading && (
|
||||||
|
<Spinner size="small" color={spinnerColor ?? "white"} />
|
||||||
|
)}
|
||||||
|
{props.children}
|
||||||
|
</MWButtonFrame>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MWButton = withStaticProperties(ButtonComponent, {});
|
||||||
|
91
apps/expo/src/hooks/useSettingsState.ts
Normal file
91
apps/expo/src/hooks/useSettingsState.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
function isEqual(x: unknown, y: unknown): boolean {
|
||||||
|
const ok = Object.keys,
|
||||||
|
tx = typeof x,
|
||||||
|
ty = typeof y;
|
||||||
|
return x && y && tx === "object" && tx === ty
|
||||||
|
? ok(x).length === ok(y).length &&
|
||||||
|
ok(x).every((key) =>
|
||||||
|
isEqual(x[key as keyof typeof x], y[key as keyof typeof y]),
|
||||||
|
)
|
||||||
|
: x === y;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDerived<T>(
|
||||||
|
initial: T,
|
||||||
|
): [T, Dispatch<SetStateAction<T>>, () => void, boolean] {
|
||||||
|
const [overwrite, setOverwrite] = useState<T | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
setOverwrite(undefined);
|
||||||
|
}, [initial]);
|
||||||
|
const changed = useMemo(
|
||||||
|
() => !isEqual(overwrite, initial) && overwrite !== undefined,
|
||||||
|
[overwrite, initial],
|
||||||
|
);
|
||||||
|
const setter = useCallback<Dispatch<SetStateAction<T>>>(
|
||||||
|
(inp) => {
|
||||||
|
if (!(inp instanceof Function)) setOverwrite(inp);
|
||||||
|
else setOverwrite((s) => inp(s ?? initial));
|
||||||
|
},
|
||||||
|
[initial, setOverwrite],
|
||||||
|
);
|
||||||
|
const data = overwrite ?? initial;
|
||||||
|
|
||||||
|
const reset = useCallback(() => setOverwrite(undefined), [setOverwrite]);
|
||||||
|
|
||||||
|
return [data, setter, reset, changed];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettingsState(
|
||||||
|
deviceName: string,
|
||||||
|
proxyUrls: string[] | null,
|
||||||
|
profile:
|
||||||
|
| {
|
||||||
|
colorA: string;
|
||||||
|
colorB: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
) {
|
||||||
|
const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] =
|
||||||
|
useDerived(proxyUrls);
|
||||||
|
|
||||||
|
const [
|
||||||
|
deviceNameState,
|
||||||
|
setDeviceNameState,
|
||||||
|
resetDeviceName,
|
||||||
|
deviceNameChanged,
|
||||||
|
] = useDerived(deviceName);
|
||||||
|
const [profileState, setProfileState, resetProfile, profileChanged] =
|
||||||
|
useDerived(profile);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
resetProxyUrls();
|
||||||
|
resetDeviceName();
|
||||||
|
resetProfile();
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = deviceNameChanged || proxyUrlsChanged || profileChanged;
|
||||||
|
|
||||||
|
return {
|
||||||
|
reset,
|
||||||
|
changed,
|
||||||
|
deviceName: {
|
||||||
|
state: deviceNameState,
|
||||||
|
set: setDeviceNameState,
|
||||||
|
changed: deviceNameChanged,
|
||||||
|
},
|
||||||
|
proxyUrls: {
|
||||||
|
state: proxyUrlsState,
|
||||||
|
set: setProxyUrls,
|
||||||
|
changed: proxyUrlsChanged,
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
state: profileState,
|
||||||
|
set: setProfileState,
|
||||||
|
changed: profileChanged,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
Reference in New Issue
Block a user