mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 16:43:25 +00:00
Compare commits
7 Commits
9694630cdf
...
0f3ccbcabc
Author | SHA1 | Date | |
---|---|---|---|
|
0f3ccbcabc | ||
|
9eb9fb494c | ||
|
bbeb729156 | ||
|
b530284519 | ||
|
fcfd0d99cc | ||
|
75f5256b20 | ||
|
eea4eab60b |
@@ -1,3 +1,4 @@
|
|||||||
import "expo-router/entry";
|
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";
|
||||||
|
@@ -41,6 +41,7 @@
|
|||||||
"expo-av": "~13.10.5",
|
"expo-av": "~13.10.5",
|
||||||
"expo-brightness": "~11.8.0",
|
"expo-brightness": "~11.8.0",
|
||||||
"expo-build-properties": "~0.11.1",
|
"expo-build-properties": "~0.11.1",
|
||||||
|
"expo-clipboard": "^5.0.1",
|
||||||
"expo-constants": "~15.4.5",
|
"expo-constants": "~15.4.5",
|
||||||
"expo-file-system": "~16.0.8",
|
"expo-file-system": "~16.0.8",
|
||||||
"expo-haptics": "~12.8.1",
|
"expo-haptics": "~12.8.1",
|
||||||
@@ -75,6 +76,7 @@
|
|||||||
"react-native-web": "^0.19.10",
|
"react-native-web": "^0.19.10",
|
||||||
"subsrt-ts": "^2.1.2",
|
"subsrt-ts": "^2.1.2",
|
||||||
"tamagui": "^1.94.0",
|
"tamagui": "^1.94.0",
|
||||||
|
"text-encoding-polyfill": "^0.6.7",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@@ -55,7 +55,7 @@ const TestDownloadButton = (props: {
|
|||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="download"
|
name="download"
|
||||||
size={24}
|
size={24}
|
||||||
color={theme.buttonSecondaryText.val}
|
color={theme.silver300.val}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from "expo-router";
|
import { Link } from "expo-router";
|
||||||
import { H2, H5, Paragraph, View } from "tamagui";
|
import { H3, H5, Paragraph, View } from "tamagui";
|
||||||
|
|
||||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||||
import { MWButton } from "~/components/ui/Button";
|
import { MWButton } from "~/components/ui/Button";
|
||||||
@@ -18,15 +18,15 @@ export default function MovieWebScreen() {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MWCard bordered>
|
<MWCard bordered padded>
|
||||||
<MWCard.Header padded>
|
<MWCard.Header>
|
||||||
<H2 fontWeight="$bold" paddingBottom="$1">
|
<H3 fontWeight="$bold" paddingBottom="$1">
|
||||||
Sync to the cloud
|
Sync to the cloud
|
||||||
</H2>
|
</H3>
|
||||||
<H5 color="$ash50" fontWeight="$semibold" paddingVertical="$3">
|
<H5 color="$shade200" fontWeight="$semibold" paddingVertical="$3">
|
||||||
Share your watch progress between devices and keep them synced.
|
Share your watch progress between devices and keep them synced.
|
||||||
</H5>
|
</H5>
|
||||||
<Paragraph color="$ash50">
|
<Paragraph color="$shade200">
|
||||||
First choose the backend you want to use. If you do not know what
|
First choose the backend you want to use. If you do not know what
|
||||||
this does, use the default and click on 'Get started'.
|
this does, use the default and click on 'Get started'.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
@@ -35,24 +35,24 @@ export default function MovieWebScreen() {
|
|||||||
<View padding="$4">
|
<View padding="$4">
|
||||||
<MWInput
|
<MWInput
|
||||||
placeholder={backendUrl}
|
placeholder={backendUrl}
|
||||||
type="search"
|
type="authentication"
|
||||||
value={backendUrl}
|
value={backendUrl}
|
||||||
onChangeText={setBackendUrl}
|
onChangeText={setBackendUrl}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<MWCard.Footer padded justifyContent="center">
|
<MWCard.Footer padded justifyContent="center">
|
||||||
<MWButton type="purple">
|
|
||||||
<Link
|
<Link
|
||||||
href={{
|
href={{
|
||||||
pathname: "/sync/trust/[url]",
|
pathname: "/sync/trust/[url]",
|
||||||
params: { url: backendUrl },
|
params: { url: backendUrl },
|
||||||
}}
|
}}
|
||||||
style={{ color: "white", fontWeight: "bold" }}
|
asChild
|
||||||
>
|
>
|
||||||
|
<MWButton type="purple" width="100%">
|
||||||
Get started
|
Get started
|
||||||
</Link>
|
|
||||||
</MWButton>
|
</MWButton>
|
||||||
|
</Link>
|
||||||
</MWCard.Footer>
|
</MWCard.Footer>
|
||||||
</MWCard>
|
</MWCard>
|
||||||
</ScreenLayout>
|
</ScreenLayout>
|
||||||
|
@@ -206,7 +206,7 @@ export default function SettingsScreen() {
|
|||||||
android: "android",
|
android: "android",
|
||||||
})}
|
})}
|
||||||
size={24}
|
size={24}
|
||||||
color={theme.buttonSecondaryText.val}
|
color={theme.silver300.val}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
iconAfter={
|
iconAfter={
|
||||||
@@ -229,7 +229,7 @@ export default function SettingsScreen() {
|
|||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="broom"
|
name="broom"
|
||||||
size={24}
|
size={24}
|
||||||
color={theme.buttonSecondaryText.val}
|
color={theme.silver300.val}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onPress={() => clearCacheDirectory()}
|
onPress={() => clearCacheDirectory()}
|
||||||
@@ -304,7 +304,7 @@ export function UpdateSheet({
|
|||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name={Platform.select({ ios: "apple", android: "android" })}
|
name={Platform.select({ ios: "apple", android: "android" })}
|
||||||
size={24}
|
size={24}
|
||||||
color={theme.buttonSecondaryText.val}
|
color={theme.silver300.val}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onPress={() => WebBrowser.openBrowserAsync(downloadUrl)}
|
onPress={() => WebBrowser.openBrowserAsync(downloadUrl)}
|
||||||
|
79
apps/expo/src/app/sync/login.tsx
Normal file
79
apps/expo/src/app/sync/login.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<ScreenLayout
|
||||||
|
showHeader={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MWCard bordered padded>
|
||||||
|
<MWCard.Header>
|
||||||
|
<H4 fontWeight="$bold" textAlign="center">
|
||||||
|
Login to your account
|
||||||
|
</H4>
|
||||||
|
|
||||||
|
<Paragraph
|
||||||
|
color="$ash50"
|
||||||
|
textAlign="center"
|
||||||
|
fontWeight="$semibold"
|
||||||
|
paddingVertical="$4"
|
||||||
|
>
|
||||||
|
Please enter your passphrase to login to your account
|
||||||
|
</Paragraph>
|
||||||
|
</MWCard.Header>
|
||||||
|
|
||||||
|
<YStack paddingBottom="$5">
|
||||||
|
<YStack gap="$1">
|
||||||
|
<Label fontWeight="$bold">12-Word passphrase</Label>
|
||||||
|
<MWInput
|
||||||
|
type="authentication"
|
||||||
|
placeholder="Passphrase"
|
||||||
|
secureTextEntry
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</YStack>
|
||||||
|
<YStack gap="$1">
|
||||||
|
<Label fontWeight="$bold">Device name</Label>
|
||||||
|
<MWInput
|
||||||
|
type="authentication"
|
||||||
|
placeholder="Personal phone"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
<MWCard.Footer
|
||||||
|
padded
|
||||||
|
justifyContent="center"
|
||||||
|
flexDirection="column"
|
||||||
|
gap="$4"
|
||||||
|
>
|
||||||
|
<MWButton type="purple">Login</MWButton>
|
||||||
|
|
||||||
|
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold">
|
||||||
|
Don't have an account yet?{"\n"}
|
||||||
|
<Text color="$purple100" fontWeight="$bold">
|
||||||
|
Create an account.
|
||||||
|
</Text>
|
||||||
|
</Paragraph>
|
||||||
|
</MWCard.Footer>
|
||||||
|
</MWCard>
|
||||||
|
</ScreenLayout>
|
||||||
|
);
|
||||||
|
}
|
190
apps/expo/src/app/sync/register/account.tsx
Normal file
190
apps/expo/src/app/sync/register/account.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
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 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]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenLayout
|
||||||
|
showHeader={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MWCard bordered padded>
|
||||||
|
<MWCard.Header>
|
||||||
|
<View alignItems="center" marginBottom="$3">
|
||||||
|
<Avatar colorA={color} colorB={color2} icon={icon} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<H4 fontWeight="$bold" textAlign="center">
|
||||||
|
Account information
|
||||||
|
</H4>
|
||||||
|
|
||||||
|
<Paragraph
|
||||||
|
color="$shade200"
|
||||||
|
textAlign="center"
|
||||||
|
fontWeight="$normal"
|
||||||
|
paddingTop="$4"
|
||||||
|
>
|
||||||
|
Enter a name for your device and pick colours and a user icon of
|
||||||
|
your choosing
|
||||||
|
</Paragraph>
|
||||||
|
</MWCard.Header>
|
||||||
|
|
||||||
|
<YStack paddingBottom="$5">
|
||||||
|
<YStack gap="$1">
|
||||||
|
<Label fontWeight="$bold">Device name</Label>
|
||||||
|
<MWInput
|
||||||
|
type="authentication"
|
||||||
|
placeholder="Passphrase"
|
||||||
|
secureTextEntry
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</YStack>
|
||||||
|
<YStack gap="$1">
|
||||||
|
<Label fontWeight="$bold">Profile color one</Label>
|
||||||
|
<ColorPicker value={color} onInput={(color) => setColor(color)} />
|
||||||
|
</YStack>
|
||||||
|
<YStack gap="$1">
|
||||||
|
<Label fontWeight="$bold">Profile color two</Label>
|
||||||
|
<ColorPicker value={color2} onInput={(color) => setColor2(color)} />
|
||||||
|
</YStack>
|
||||||
|
<YStack gap="$1">
|
||||||
|
<Label fontWeight="$bold">User icon</Label>
|
||||||
|
<UserIconPicker value={icon} onInput={(icon) => setIcon(icon)} />
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
<MWCard.Footer justifyContent="center" flexDirection="column" gap="$4">
|
||||||
|
<Link
|
||||||
|
href={{
|
||||||
|
pathname: "/sync/register/confirm",
|
||||||
|
}}
|
||||||
|
replace
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<MWButton type="purple" width="100%">
|
||||||
|
Next
|
||||||
|
</MWButton>
|
||||||
|
</Link>
|
||||||
|
</MWCard.Footer>
|
||||||
|
</MWCard>
|
||||||
|
</ScreenLayout>
|
||||||
|
);
|
||||||
|
}
|
68
apps/expo/src/app/sync/register/confirm.tsx
Normal file
68
apps/expo/src/app/sync/register/confirm.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Link, Stack } from "expo-router";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<ScreenLayout
|
||||||
|
showHeader={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MWCard bordered padded>
|
||||||
|
<MWCard.Header>
|
||||||
|
<H4 fontWeight="$bold" textAlign="center">
|
||||||
|
Confirm your passphrase
|
||||||
|
</H4>
|
||||||
|
|
||||||
|
<Paragraph
|
||||||
|
color="$shade200"
|
||||||
|
textAlign="center"
|
||||||
|
fontWeight="$normal"
|
||||||
|
paddingTop="$4"
|
||||||
|
>
|
||||||
|
Please enter your passphrase from earlier to confirm you have saved
|
||||||
|
it and to create your account
|
||||||
|
</Paragraph>
|
||||||
|
</MWCard.Header>
|
||||||
|
|
||||||
|
<YStack paddingBottom="$5">
|
||||||
|
<YStack gap="$1">
|
||||||
|
<Label fontWeight="$bold">12-Word passphrase</Label>
|
||||||
|
<MWInput
|
||||||
|
type="authentication"
|
||||||
|
placeholder="Passphrase"
|
||||||
|
secureTextEntry
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</MWCard.Footer>
|
||||||
|
</MWCard>
|
||||||
|
</ScreenLayout>
|
||||||
|
);
|
||||||
|
}
|
132
apps/expo/src/app/sync/register/index.tsx
Normal file
132
apps/expo/src/app/sync/register/index.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { TouchableOpacity } from "react-native-gesture-handler";
|
||||||
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import { Link, Stack } from "expo-router";
|
||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
import { H4, Paragraph, Text, useTheme, View, XStack, YStack } from "tamagui";
|
||||||
|
|
||||||
|
import { genMnemonic } from "@movie-web/api";
|
||||||
|
|
||||||
|
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||||
|
import { MWButton } from "~/components/ui/Button";
|
||||||
|
import { MWCard } from "~/components/ui/Card";
|
||||||
|
|
||||||
|
function PassphraseWord({ word }: { word: string }) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
width="$10"
|
||||||
|
borderRadius="$4"
|
||||||
|
paddingHorizontal="$4"
|
||||||
|
paddingVertical="$3"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
backgroundColor="$shade400"
|
||||||
|
>
|
||||||
|
<Text fontWeight="$bold">{word}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const words = genMnemonic().split(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenLayout
|
||||||
|
showHeader={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MWCard bordered padded>
|
||||||
|
<MWCard.Header>
|
||||||
|
<H4 fontWeight="$bold" textAlign="center">
|
||||||
|
Your passphrase
|
||||||
|
</H4>
|
||||||
|
|
||||||
|
<Paragraph
|
||||||
|
color="$shade200"
|
||||||
|
textAlign="center"
|
||||||
|
fontWeight="$normal"
|
||||||
|
paddingTop="$4"
|
||||||
|
>
|
||||||
|
Your passphrase acts as your username and password. Make sure to
|
||||||
|
keep it safe as you will need to enter it to login to your account
|
||||||
|
</Paragraph>
|
||||||
|
</MWCard.Header>
|
||||||
|
|
||||||
|
<YStack
|
||||||
|
borderRadius="$4"
|
||||||
|
borderColor="$shade200"
|
||||||
|
borderWidth="$0.5"
|
||||||
|
marginBottom="$4"
|
||||||
|
>
|
||||||
|
<XStack
|
||||||
|
gap="$1"
|
||||||
|
borderBottomWidth="$0.5"
|
||||||
|
borderColor="$shade200"
|
||||||
|
paddingVertical="$2"
|
||||||
|
paddingHorizontal="$4"
|
||||||
|
>
|
||||||
|
<Text fontWeight="$bold" flexGrow={1}>
|
||||||
|
Passphrase
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
onPress={async () => {
|
||||||
|
await Clipboard.setStringAsync(words.join(""));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Feather name="copy" size={18} color={theme.shade200.val} />
|
||||||
|
<Text color="$shade200" fontWeight="$bold">
|
||||||
|
Copy
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</XStack>
|
||||||
|
<View
|
||||||
|
flexWrap="wrap"
|
||||||
|
flexDirection="row"
|
||||||
|
gap="$4"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
padding="$3"
|
||||||
|
>
|
||||||
|
{words.map((word, index) => (
|
||||||
|
<PassphraseWord key={index} word={word} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
|
<MWCard.Footer justifyContent="center" flexDirection="column" gap="$4">
|
||||||
|
<Link
|
||||||
|
href={{
|
||||||
|
pathname: "/sync/register/account",
|
||||||
|
}}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<MWButton type="purple">I have saved my passphrase</MWButton>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold">
|
||||||
|
Already have an account?{"\n"}
|
||||||
|
<Text color="$purple100" fontWeight="$bold">
|
||||||
|
<Link href="/sync/login">Login here</Link>
|
||||||
|
</Text>
|
||||||
|
</Paragraph>
|
||||||
|
</MWCard.Footer>
|
||||||
|
</MWCard>
|
||||||
|
</ScreenLayout>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,30 +1,19 @@
|
|||||||
import { Stack, useLocalSearchParams } from "expo-router";
|
import { Link, Stack, useLocalSearchParams } from "expo-router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { H4, Paragraph, Text, View } from "tamagui";
|
import { H4, Paragraph, Text, View } from "tamagui";
|
||||||
|
|
||||||
|
import { getBackendMeta } from "@movie-web/api";
|
||||||
|
|
||||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||||
import { MWButton } from "~/components/ui/Button";
|
import { MWButton } from "~/components/ui/Button";
|
||||||
import { MWCard } from "~/components/ui/Card";
|
import { MWCard } from "~/components/ui/Card";
|
||||||
|
|
||||||
// TODO: extract to function with cleanup and types
|
|
||||||
const getBackendMeta = (
|
|
||||||
url: string,
|
|
||||||
): Promise<{
|
|
||||||
description: string;
|
|
||||||
hasCaptcha: boolean;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
}> => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
||||||
return fetch(`${url}/meta`).then((res) => res.json());
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { url } = useLocalSearchParams();
|
const { url } = useLocalSearchParams<{ url: string }>();
|
||||||
|
|
||||||
const meta = useQuery({
|
const meta = useQuery({
|
||||||
queryKey: ["backendMeta", url],
|
queryKey: ["backendMeta", url],
|
||||||
queryFn: () => getBackendMeta(url as string),
|
queryFn: () => getBackendMeta(url),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,7 +30,7 @@ export default function Page() {
|
|||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<MWCard bordered>
|
<MWCard bordered padded>
|
||||||
<MWCard.Header padded>
|
<MWCard.Header padded>
|
||||||
<H4 fontWeight="$bold" textAlign="center">
|
<H4 fontWeight="$bold" textAlign="center">
|
||||||
Do you trust this server?
|
Do you trust this server?
|
||||||
@@ -100,16 +89,24 @@ export default function Page() {
|
|||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap="$4"
|
gap="$4"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={{
|
||||||
|
pathname: "/sync/register",
|
||||||
|
}}
|
||||||
|
asChild
|
||||||
>
|
>
|
||||||
<MWButton type="purple">I trust this server</MWButton>
|
<MWButton type="purple">I trust this server</MWButton>
|
||||||
<MWButton type="secondary">Go back</MWButton>
|
</Link>
|
||||||
|
<Link
|
||||||
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold">
|
href={{
|
||||||
Already have an account?{" "}
|
pathname: "/(tabs)/",
|
||||||
<Text color="$purple100" fontWeight="$bold">
|
}}
|
||||||
Login here
|
replace
|
||||||
</Text>
|
asChild
|
||||||
</Paragraph>
|
>
|
||||||
|
<MWButton type="cancel">Go back</MWButton>
|
||||||
|
</Link>
|
||||||
</MWCard.Footer>
|
</MWCard.Footer>
|
||||||
</MWCard>
|
</MWCard>
|
||||||
</ScreenLayout>
|
</ScreenLayout>
|
||||||
|
@@ -58,7 +58,7 @@ export const AudioTrackSelector = () => {
|
|||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="volume-high"
|
name="volume-high"
|
||||||
size={24}
|
size={24}
|
||||||
color={theme.buttonSecondaryText.val}
|
color={theme.silver300.val}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onPress={() => setOpen(true)}
|
onPress={() => setOpen(true)}
|
||||||
|
@@ -65,7 +65,7 @@ export const CaptionsSelector = () => {
|
|||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="subtitles"
|
name="subtitles"
|
||||||
size={24}
|
size={24}
|
||||||
color={theme.buttonSecondaryText.val}
|
color={theme.silver300.val}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onPress={() => setOpen(true)}
|
onPress={() => setOpen(true)}
|
||||||
|
@@ -38,7 +38,7 @@ export const DownloadButton = () => {
|
|||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="download"
|
name="download"
|
||||||
size={24}
|
size={24}
|
||||||
color={theme.buttonSecondaryText.val}
|
color={theme.silver300.val}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
|
@@ -47,7 +47,7 @@ const EpisodeSelector = ({
|
|||||||
<Ionicons
|
<Ionicons
|
||||||
name="arrow-back"
|
name="arrow-back"
|
||||||
size={24}
|
size={24}
|
||||||
color={theme.buttonSecondaryText.val}
|
color={theme.silver300.val}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setSelectedSeason(null);
|
setSelectedSeason(null);
|
||||||
props.onOpenChange?.(false);
|
props.onOpenChange?.(false);
|
||||||
@@ -119,7 +119,7 @@ export const SeasonSelector = () => {
|
|||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="audio-video"
|
name="audio-video"
|
||||||
size={24}
|
size={24}
|
||||||
color={theme.buttonSecondaryText.val}
|
color={theme.silver300.val}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onPress={() => setOpen(true)}
|
onPress={() => setOpen(true)}
|
||||||
|
@@ -23,7 +23,7 @@ export const SettingsSelector = () => {
|
|||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name="display-settings"
|
name="display-settings"
|
||||||
size={24}
|
size={24}
|
||||||
color={theme.buttonSecondaryText.val}
|
color={theme.silver300.val}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onPress={() => setOpen(true)}
|
onPress={() => setOpen(true)}
|
||||||
|
@@ -102,7 +102,7 @@ const EmbedsPart = ({
|
|||||||
<Ionicons
|
<Ionicons
|
||||||
name="arrow-back"
|
name="arrow-back"
|
||||||
size={24}
|
size={24}
|
||||||
color={theme.buttonSecondaryText.val}
|
color={theme.silver300.val}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
props.onOpenChange?.(false);
|
props.onOpenChange?.(false);
|
||||||
}}
|
}}
|
||||||
@@ -160,7 +160,7 @@ export const SourceSelector = () => {
|
|||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="video"
|
name="video"
|
||||||
size={24}
|
size={24}
|
||||||
color={theme.buttonSecondaryText.val}
|
color={theme.silver300.val}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onPress={() => setOpen(true)}
|
onPress={() => setOpen(true)}
|
||||||
|
@@ -4,35 +4,35 @@ export const MWButton = styled(Button, {
|
|||||||
variants: {
|
variants: {
|
||||||
type: {
|
type: {
|
||||||
primary: {
|
primary: {
|
||||||
backgroundColor: "$buttonPrimaryBackground",
|
backgroundColor: "white",
|
||||||
color: "$buttonPrimaryText",
|
color: "black",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
pressStyle: {
|
pressStyle: {
|
||||||
backgroundColor: "$buttonPrimaryBackgroundHover",
|
backgroundColor: "$silver100",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
backgroundColor: "$buttonSecondaryBackground",
|
backgroundColor: "$ash700",
|
||||||
color: "$buttonSecondaryText",
|
color: "$silver300",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
pressStyle: {
|
pressStyle: {
|
||||||
backgroundColor: "$buttonSecondaryBackgroundHover",
|
backgroundColor: "$ash500",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
purple: {
|
purple: {
|
||||||
backgroundColor: "$buttonPurpleBackground",
|
backgroundColor: "$purple500",
|
||||||
color: "white",
|
color: "white",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
pressStyle: {
|
pressStyle: {
|
||||||
backgroundColor: "$buttonPurpleBackgroundHover",
|
backgroundColor: "$purple400",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
cancel: {
|
cancel: {
|
||||||
backgroundColor: "$buttonCancelBackground",
|
backgroundColor: "$ash500",
|
||||||
color: "white",
|
color: "white",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
pressStyle: {
|
pressStyle: {
|
||||||
backgroundColor: "$buttonCancelBackgroundHover",
|
backgroundColor: "$ash300",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { Card, styled, withStaticProperties } from "tamagui";
|
import { Card, styled, withStaticProperties } from "tamagui";
|
||||||
|
|
||||||
export const MWCardFrame = styled(Card, {
|
export const MWCardFrame = styled(Card, {
|
||||||
backgroundColor: "$shade400",
|
backgroundColor: "$shade600",
|
||||||
borderColor: "$shade400",
|
borderColor: "$shade400",
|
||||||
|
|
||||||
variants: {
|
variants: {
|
||||||
|
@@ -6,21 +6,37 @@ export const MWInput = styled(Input, {
|
|||||||
variants: {
|
variants: {
|
||||||
type: {
|
type: {
|
||||||
default: {
|
default: {
|
||||||
backgroundColor: "$inputBackground",
|
backgroundColor: "$ash600",
|
||||||
color: "$inputText",
|
color: "$ash100",
|
||||||
placeholderTextColor: "$placeHolderText",
|
placeholderTextColor: "$ash200",
|
||||||
borderColor: "$inputBorder",
|
borderColor: "$ash500",
|
||||||
outlineStyle: "none",
|
outlineStyle: "none",
|
||||||
|
focusStyle: {
|
||||||
|
borderColor: "$ash300",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
backgroundColor: "$searchBackground",
|
backgroundColor: "$shade500",
|
||||||
|
color: "$shade100",
|
||||||
borderColor: "$colorTransparent",
|
borderColor: "$colorTransparent",
|
||||||
placeholderTextColor: "$searchPlaceholder",
|
placeholderTextColor: "$shade100",
|
||||||
outlineStyle: "none",
|
outlineStyle: "none",
|
||||||
focusStyle: {
|
focusStyle: {
|
||||||
borderColor: "$colorTransparent",
|
borderColor: "$colorTransparent",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
authentication: {
|
||||||
|
backgroundColor: "$shade500",
|
||||||
|
color: "$shade100",
|
||||||
|
placeholderTextColor: "$shade400",
|
||||||
|
outlineStyle: "none",
|
||||||
|
focusStyle: {
|
||||||
|
borderColor: "$shade300",
|
||||||
|
},
|
||||||
|
pressStyle: {
|
||||||
|
backgroundColor: "$shade500",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
204
apps/expo/src/hooks/useAuth.ts
Normal file
204
apps/expo/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AccountWithToken,
|
||||||
|
BookmarkMediaItem,
|
||||||
|
ProgressMediaItem,
|
||||||
|
SessionResponse,
|
||||||
|
UserResponse,
|
||||||
|
} from "@movie-web/api";
|
||||||
|
import {
|
||||||
|
bookmarkMediaToInput,
|
||||||
|
bytesToBase64,
|
||||||
|
bytesToBase64Url,
|
||||||
|
encryptData,
|
||||||
|
getBookmarks,
|
||||||
|
getLoginChallengeToken,
|
||||||
|
getProgress,
|
||||||
|
getRegisterChallengeToken,
|
||||||
|
getSettings,
|
||||||
|
getUser,
|
||||||
|
importBookmarks,
|
||||||
|
importProgress,
|
||||||
|
keysFromMnemonic,
|
||||||
|
loginAccount,
|
||||||
|
progressMediaItemToInputs,
|
||||||
|
registerAccount,
|
||||||
|
removeSession,
|
||||||
|
signChallenge,
|
||||||
|
} from "@movie-web/api";
|
||||||
|
|
||||||
|
import { useAuthStore } from "~/stores/settings";
|
||||||
|
import { useAuthData } from "./useAuthData";
|
||||||
|
|
||||||
|
export interface RegistrationData {
|
||||||
|
recaptchaToken?: string;
|
||||||
|
mnemonic: string;
|
||||||
|
userData: {
|
||||||
|
device: string;
|
||||||
|
profile: {
|
||||||
|
colorA: string;
|
||||||
|
colorB: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginData {
|
||||||
|
mnemonic: string;
|
||||||
|
userData: {
|
||||||
|
device: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const currentAccount = useAuthStore((s) => s.account);
|
||||||
|
const profile = useAuthStore((s) => s.account?.profile);
|
||||||
|
const loggedIn = !!useAuthStore((s) => s.account);
|
||||||
|
const backendUrl = useAuthStore((s) => s.backendUrl);
|
||||||
|
const {
|
||||||
|
logout: userDataLogout,
|
||||||
|
login: userDataLogin,
|
||||||
|
syncData,
|
||||||
|
} = useAuthData();
|
||||||
|
|
||||||
|
const login = useCallback(
|
||||||
|
async (loginData: LoginData) => {
|
||||||
|
if (!backendUrl) return;
|
||||||
|
const keys = await keysFromMnemonic(loginData.mnemonic);
|
||||||
|
const publicKeyBase64Url = bytesToBase64Url(keys.publicKey);
|
||||||
|
const { challenge } = await getLoginChallengeToken(
|
||||||
|
backendUrl,
|
||||||
|
publicKeyBase64Url,
|
||||||
|
);
|
||||||
|
const signature = signChallenge(keys, challenge);
|
||||||
|
const loginResult = await loginAccount(backendUrl, {
|
||||||
|
challenge: {
|
||||||
|
code: challenge,
|
||||||
|
signature,
|
||||||
|
},
|
||||||
|
publicKey: publicKeyBase64Url,
|
||||||
|
device: await encryptData(loginData.userData.device, keys.seed),
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await getUser(backendUrl, loginResult.token);
|
||||||
|
const seedBase64 = bytesToBase64(keys.seed);
|
||||||
|
return userDataLogin(loginResult, user.user, user.session, seedBase64);
|
||||||
|
},
|
||||||
|
[userDataLogin, backendUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
if (!currentAccount || !backendUrl) return;
|
||||||
|
try {
|
||||||
|
await removeSession(
|
||||||
|
backendUrl,
|
||||||
|
currentAccount.token,
|
||||||
|
currentAccount.sessionId,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// we dont care about failing to delete session
|
||||||
|
}
|
||||||
|
userDataLogout();
|
||||||
|
}, [userDataLogout, backendUrl, currentAccount]);
|
||||||
|
|
||||||
|
const register = useCallback(
|
||||||
|
async (registerData: RegistrationData) => {
|
||||||
|
if (!backendUrl) return;
|
||||||
|
const { challenge } = await getRegisterChallengeToken(
|
||||||
|
backendUrl,
|
||||||
|
registerData.recaptchaToken,
|
||||||
|
);
|
||||||
|
const keys = await keysFromMnemonic(registerData.mnemonic);
|
||||||
|
const signature = signChallenge(keys, challenge);
|
||||||
|
const registerResult = await registerAccount(backendUrl, {
|
||||||
|
challenge: {
|
||||||
|
code: challenge,
|
||||||
|
signature,
|
||||||
|
},
|
||||||
|
publicKey: bytesToBase64Url(keys.publicKey),
|
||||||
|
device: await encryptData(registerData.userData.device, keys.seed),
|
||||||
|
profile: registerData.userData.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
return userDataLogin(
|
||||||
|
registerResult,
|
||||||
|
registerResult.user,
|
||||||
|
registerResult.session,
|
||||||
|
bytesToBase64(keys.seed),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[backendUrl, userDataLogin],
|
||||||
|
);
|
||||||
|
|
||||||
|
const importData = useCallback(
|
||||||
|
async (
|
||||||
|
account: AccountWithToken,
|
||||||
|
progressItems: Record<string, ProgressMediaItem>,
|
||||||
|
bookmarks: Record<string, BookmarkMediaItem>,
|
||||||
|
) => {
|
||||||
|
if (!backendUrl) return;
|
||||||
|
if (
|
||||||
|
Object.keys(progressItems).length === 0 &&
|
||||||
|
Object.keys(bookmarks).length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressInputs = Object.entries(progressItems).flatMap(
|
||||||
|
([tmdbId, item]) => progressMediaItemToInputs(tmdbId, item),
|
||||||
|
);
|
||||||
|
|
||||||
|
const bookmarkInputs = Object.entries(bookmarks).map(([tmdbId, item]) =>
|
||||||
|
bookmarkMediaToInput(tmdbId, item),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
importProgress(backendUrl, account, progressInputs),
|
||||||
|
importBookmarks(backendUrl, account, bookmarkInputs),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[backendUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const restore = useCallback(
|
||||||
|
async (account: AccountWithToken) => {
|
||||||
|
if (!backendUrl) return;
|
||||||
|
let user: { user: UserResponse; session: SessionResponse };
|
||||||
|
try {
|
||||||
|
user = await getUser(backendUrl, account.token);
|
||||||
|
} catch (err) {
|
||||||
|
const anyError = err as { response?: { status: number } };
|
||||||
|
if (
|
||||||
|
anyError?.response?.status === 401 ||
|
||||||
|
anyError?.response?.status === 403 ||
|
||||||
|
anyError?.response?.status === 400
|
||||||
|
) {
|
||||||
|
await logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [bookmarks, progress, settings] = await Promise.all([
|
||||||
|
getBookmarks(backendUrl, account),
|
||||||
|
getProgress(backendUrl, account),
|
||||||
|
getSettings(backendUrl, account),
|
||||||
|
]);
|
||||||
|
|
||||||
|
syncData(user.user, user.session, progress, bookmarks, settings);
|
||||||
|
},
|
||||||
|
[backendUrl, syncData, logout],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loggedIn,
|
||||||
|
profile,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
register,
|
||||||
|
restore,
|
||||||
|
importData,
|
||||||
|
};
|
||||||
|
}
|
170
apps/expo/src/hooks/useAuthData.ts
Normal file
170
apps/expo/src/hooks/useAuthData.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BookmarkResponse,
|
||||||
|
LoginResponse,
|
||||||
|
ProgressResponse,
|
||||||
|
SessionResponse,
|
||||||
|
SettingsResponse,
|
||||||
|
UserResponse,
|
||||||
|
} from "@movie-web/api";
|
||||||
|
import type { ScrapeMedia } from "@movie-web/provider-utils";
|
||||||
|
|
||||||
|
import type { ItemData } from "~/components/item/item";
|
||||||
|
import type { WatchHistoryItem } from "~/stores/settings";
|
||||||
|
import type { ThemeStoreOption } from "~/stores/theme";
|
||||||
|
import {
|
||||||
|
useAuthStore,
|
||||||
|
useBookmarkStore,
|
||||||
|
useWatchHistoryStore,
|
||||||
|
} from "~/stores/settings";
|
||||||
|
import { useThemeStore } from "~/stores/theme";
|
||||||
|
|
||||||
|
export function useAuthData() {
|
||||||
|
const loggedIn = !!useAuthStore((s) => s.account);
|
||||||
|
const setAccount = useAuthStore((s) => s.setAccount);
|
||||||
|
const removeAccount = useAuthStore((s) => s.removeAccount);
|
||||||
|
const setBookmarks = useBookmarkStore((s) => s.setBookmarks);
|
||||||
|
const setProxySet = useAuthStore((s) => s.setProxySet);
|
||||||
|
const setWatchHistory = useWatchHistoryStore((s) => s.setWatchHistory);
|
||||||
|
const clearBookmarks = useCallback(() => setBookmarks([]), [setBookmarks]);
|
||||||
|
const clearProgress = useCallback(
|
||||||
|
() => setWatchHistory([]),
|
||||||
|
[setWatchHistory],
|
||||||
|
);
|
||||||
|
const replaceBookmarks = useCallback(
|
||||||
|
(bookmarks: ItemData[]) => setBookmarks(bookmarks),
|
||||||
|
[setBookmarks],
|
||||||
|
);
|
||||||
|
const replaceItems = useCallback(
|
||||||
|
(items: WatchHistoryItem[]) => setWatchHistory(items),
|
||||||
|
[setWatchHistory],
|
||||||
|
);
|
||||||
|
const setTheme = useThemeStore((s) => s.setTheme);
|
||||||
|
|
||||||
|
// const setAppLanguage = useLanguageStore((s) => s.setLanguage);
|
||||||
|
// const importSubtitleLanguage = useSubtitleStore(
|
||||||
|
// (s) => s.importSubtitleLanguage,
|
||||||
|
// );
|
||||||
|
|
||||||
|
const login = useCallback(
|
||||||
|
(
|
||||||
|
loginResponse: LoginResponse,
|
||||||
|
user: UserResponse,
|
||||||
|
session: SessionResponse,
|
||||||
|
seed: string,
|
||||||
|
) => {
|
||||||
|
const account = {
|
||||||
|
token: loginResponse.token,
|
||||||
|
userId: user.id,
|
||||||
|
sessionId: loginResponse.session.id,
|
||||||
|
deviceName: session.device,
|
||||||
|
profile: user.profile,
|
||||||
|
seed,
|
||||||
|
};
|
||||||
|
setAccount(account);
|
||||||
|
return account;
|
||||||
|
},
|
||||||
|
[setAccount],
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
removeAccount();
|
||||||
|
clearBookmarks();
|
||||||
|
clearProgress();
|
||||||
|
}, [removeAccount, clearBookmarks, clearProgress]);
|
||||||
|
|
||||||
|
const syncData = useCallback(
|
||||||
|
(
|
||||||
|
_user: UserResponse,
|
||||||
|
_session: SessionResponse,
|
||||||
|
progress: ProgressResponse[],
|
||||||
|
bookmarks: BookmarkResponse[],
|
||||||
|
settings: SettingsResponse,
|
||||||
|
) => {
|
||||||
|
const bookmarkResponseToItemData = (
|
||||||
|
bookmarks: BookmarkResponse[],
|
||||||
|
): ItemData[] => {
|
||||||
|
return bookmarks.map((bookmark) => ({
|
||||||
|
id: bookmark.tmdbId,
|
||||||
|
title: bookmark.meta.title,
|
||||||
|
type: bookmark.meta.type === "show" ? "tv" : "movie",
|
||||||
|
year: bookmark.meta.year,
|
||||||
|
posterUrl: bookmark.meta.poster ?? "",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressResponseToWatchHistoryItem = (
|
||||||
|
progress: ProgressResponse[],
|
||||||
|
): WatchHistoryItem[] => {
|
||||||
|
return progress.map((entry) => {
|
||||||
|
const isShow = entry.meta.type === "show";
|
||||||
|
const commonMedia = {
|
||||||
|
title: entry.meta.title,
|
||||||
|
releaseYear: entry.meta.year,
|
||||||
|
tmdbId: entry.tmdbId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const media: ScrapeMedia = isShow
|
||||||
|
? {
|
||||||
|
...commonMedia,
|
||||||
|
type: "show",
|
||||||
|
season: {
|
||||||
|
number: entry.season.number ?? 0,
|
||||||
|
tmdbId: entry.season.id ?? "",
|
||||||
|
},
|
||||||
|
episode: {
|
||||||
|
number: entry.episode.number ?? 0,
|
||||||
|
tmdbId: entry.episode.id ?? "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...commonMedia,
|
||||||
|
type: "movie",
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
id: entry.tmdbId,
|
||||||
|
title: entry.meta.title,
|
||||||
|
type: entry.meta.type === "show" ? "tv" : "movie",
|
||||||
|
season: entry.season.number,
|
||||||
|
episode: entry.episode.number,
|
||||||
|
year: entry.meta.year,
|
||||||
|
posterUrl: entry.meta.poster ?? "",
|
||||||
|
},
|
||||||
|
media: media,
|
||||||
|
positionMillis: parseInt(entry.watched, 10),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
replaceBookmarks(bookmarkResponseToItemData(bookmarks));
|
||||||
|
replaceItems(progressResponseToWatchHistoryItem(progress));
|
||||||
|
|
||||||
|
// if (settings.applicationLanguage) {
|
||||||
|
// setAppLanguage(settings.applicationLanguage);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (settings.defaultSubtitleLanguage) {
|
||||||
|
// importSubtitleLanguage(settings.defaultSubtitleLanguage);
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (settings.applicationTheme) {
|
||||||
|
setTheme(settings.applicationTheme as unknown as ThemeStoreOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.proxyUrls) {
|
||||||
|
setProxySet(settings.proxyUrls);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[replaceBookmarks, replaceItems, setTheme, setProxySet],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loggedIn,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
syncData,
|
||||||
|
};
|
||||||
|
}
|
@@ -144,7 +144,7 @@ export const useBookmarkStore = create<
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
interface WatchHistoryItem {
|
export interface WatchHistoryItem {
|
||||||
item: ItemData;
|
item: ItemData;
|
||||||
media: ScrapeMedia;
|
media: ScrapeMedia;
|
||||||
positionMillis: number;
|
positionMillis: number;
|
||||||
|
@@ -57,17 +57,6 @@ const createThemeConfig = (tokens: Tokens) => ({
|
|||||||
|
|
||||||
loadingIndicator: tokens.purple.c200,
|
loadingIndicator: tokens.purple.c200,
|
||||||
|
|
||||||
buttonSecondaryBackground: tokens.ash.c700,
|
|
||||||
buttonSecondaryText: tokens.semantic.silver.c300,
|
|
||||||
buttonSecondaryBackgroundHover: tokens.ash.c700,
|
|
||||||
buttonPrimaryBackground: tokens.white,
|
|
||||||
buttonPrimaryText: tokens.black,
|
|
||||||
buttonPrimaryBackgroundHover: tokens.semantic.silver.c100,
|
|
||||||
buttonPurpleBackground: tokens.purple.c500,
|
|
||||||
buttonPurpleBackgroundHover: tokens.purple.c400,
|
|
||||||
buttonCancelBackground: tokens.ash.c500,
|
|
||||||
buttonCancelBackgroundHover: tokens.ash.c300,
|
|
||||||
|
|
||||||
switchActiveTrackColor: tokens.purple.c300,
|
switchActiveTrackColor: tokens.purple.c300,
|
||||||
switchInactiveTrackColor: tokens.ash.c500,
|
switchInactiveTrackColor: tokens.ash.c500,
|
||||||
switchThumbColor: tokens.white,
|
switchThumbColor: tokens.white,
|
||||||
|
@@ -32,7 +32,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^1.4.0",
|
"@noble/hashes": "^1.4.0",
|
||||||
"@scure/bip39": "^1.3.0",
|
"@scure/bip39": "^1.3.0",
|
||||||
"node-forge": "^1.3.1",
|
"node-forge": "^1.3.1"
|
||||||
"ofetch": "^1.3.4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import { ofetch } from "ofetch";
|
|
||||||
|
|
||||||
import type { LoginResponse } from "./types";
|
import type { LoginResponse } from "./types";
|
||||||
|
import { f } from "./fetch";
|
||||||
|
|
||||||
export function getAuthHeaders(token: string): Record<string, string> {
|
export function getAuthHeaders(token: string): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
@@ -13,12 +12,12 @@ export async function accountLogin(
|
|||||||
id: string,
|
id: string,
|
||||||
deviceName: string,
|
deviceName: string,
|
||||||
): Promise<LoginResponse> {
|
): Promise<LoginResponse> {
|
||||||
return ofetch<LoginResponse>("/auth/login", {
|
return f<LoginResponse>("/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
id,
|
id,
|
||||||
device: deviceName,
|
device: deviceName,
|
||||||
},
|
},
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,3 @@
|
|||||||
import { ofetch } from "ofetch";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AccountWithToken,
|
AccountWithToken,
|
||||||
BookmarkInput,
|
BookmarkInput,
|
||||||
@@ -7,6 +5,7 @@ import type {
|
|||||||
BookmarkResponse,
|
BookmarkResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { getAuthHeaders } from "./auth";
|
import { getAuthHeaders } from "./auth";
|
||||||
|
import { f } from "./fetch";
|
||||||
|
|
||||||
export function bookmarkMediaToInput(
|
export function bookmarkMediaToInput(
|
||||||
tmdbId: string,
|
tmdbId: string,
|
||||||
@@ -28,12 +27,12 @@ export async function addBookmark(
|
|||||||
account: AccountWithToken,
|
account: AccountWithToken,
|
||||||
input: BookmarkInput,
|
input: BookmarkInput,
|
||||||
) {
|
) {
|
||||||
return ofetch<BookmarkResponse>(
|
return f<BookmarkResponse>(
|
||||||
`/users/${account.userId}/bookmarks/${input.tmdbId}`,
|
`/users/${account.userId}/bookmarks/${input.tmdbId}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: getAuthHeaders(account.token),
|
headers: getAuthHeaders(account.token),
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
body: input,
|
body: input,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -44,12 +43,9 @@ export async function removeBookmark(
|
|||||||
account: AccountWithToken,
|
account: AccountWithToken,
|
||||||
id: string,
|
id: string,
|
||||||
) {
|
) {
|
||||||
return ofetch<{ tmdbId: string }>(
|
return f<{ tmdbId: string }>(`/users/${account.userId}/bookmarks/${id}`, {
|
||||||
`/users/${account.userId}/bookmarks/${id}`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: getAuthHeaders(account.token),
|
headers: getAuthHeaders(account.token),
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@@ -39,11 +39,7 @@ export function genMnemonic(): string {
|
|||||||
return generateMnemonic(wordlist);
|
return generateMnemonic(wordlist);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
export function signCode(code: string, privateKey: Uint8Array): Uint8Array {
|
||||||
export async function signCode(
|
|
||||||
code: string,
|
|
||||||
privateKey: Uint8Array,
|
|
||||||
): Promise<Uint8Array> {
|
|
||||||
return forge.pki.ed25519.sign({
|
return forge.pki.ed25519.sign({
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
message: code,
|
message: code,
|
||||||
@@ -62,8 +58,8 @@ export function bytesToBase64Url(bytes: Uint8Array): string {
|
|||||||
.replace(/=+$/, "");
|
.replace(/=+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signChallenge(keys: Keys, challengeCode: string) {
|
export function signChallenge(keys: Keys, challengeCode: string) {
|
||||||
const signature = await signCode(challengeCode, keys.privateKey);
|
const signature = signCode(challengeCode, keys.privateKey);
|
||||||
return bytesToBase64Url(signature);
|
return bytesToBase64Url(signature);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
53
packages/api/src/fetch.ts
Normal file
53
packages/api/src/fetch.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export interface FetcherOptions {
|
||||||
|
baseUrl?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
query?: Record<string, string>;
|
||||||
|
method?: "HEAD" | "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||||
|
readHeaders?: string[];
|
||||||
|
body?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FullUrlOptions = Pick<FetcherOptions, "query" | "baseUrl">;
|
||||||
|
|
||||||
|
export function makeFullUrl(url: string, ops?: FullUrlOptions): string {
|
||||||
|
// glue baseUrl and rest of url together
|
||||||
|
let leftSide = ops?.baseUrl ?? "";
|
||||||
|
let rightSide = url;
|
||||||
|
|
||||||
|
// left side should always end with slash, if its set
|
||||||
|
if (leftSide.length > 0 && !leftSide.endsWith("/")) leftSide += "/";
|
||||||
|
|
||||||
|
// right side should never start with slash
|
||||||
|
if (rightSide.startsWith("/")) rightSide = rightSide.slice(1);
|
||||||
|
|
||||||
|
const fullUrl = leftSide + rightSide;
|
||||||
|
if (!fullUrl.startsWith("http://") && !fullUrl.startsWith("https://"))
|
||||||
|
throw new Error(
|
||||||
|
`Invald URL -- URL doesn't start with a http scheme: '${fullUrl}'`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedUrl = new URL(fullUrl);
|
||||||
|
Object.entries(ops?.query ?? {}).forEach(([k, v]) => {
|
||||||
|
parsedUrl.searchParams.set(k, v);
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsedUrl.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
body: ops?.body ? JSON.stringify(ops.body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch '${fullUrl}' -- ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as T;
|
||||||
|
return data;
|
||||||
|
}
|
@@ -1,17 +1,16 @@
|
|||||||
import { ofetch } from "ofetch";
|
|
||||||
|
|
||||||
import type { AccountWithToken, BookmarkInput, ProgressInput } from "./types";
|
import type { AccountWithToken, BookmarkInput, ProgressInput } from "./types";
|
||||||
import { getAuthHeaders } from "./auth";
|
import { getAuthHeaders } from "./auth";
|
||||||
|
import { f } from "./fetch";
|
||||||
|
|
||||||
export function importProgress(
|
export function importProgress(
|
||||||
url: string,
|
url: string,
|
||||||
account: AccountWithToken,
|
account: AccountWithToken,
|
||||||
progressItems: ProgressInput[],
|
progressItems: ProgressInput[],
|
||||||
) {
|
) {
|
||||||
return ofetch<void>(`/users/${account.userId}/progress/import`, {
|
return f<void>(`/users/${account.userId}/progress/import`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: progressItems,
|
body: progressItems,
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
headers: getAuthHeaders(account.token),
|
headers: getAuthHeaders(account.token),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -21,10 +20,10 @@ export function importBookmarks(
|
|||||||
account: AccountWithToken,
|
account: AccountWithToken,
|
||||||
bookmarks: BookmarkInput[],
|
bookmarks: BookmarkInput[],
|
||||||
) {
|
) {
|
||||||
return ofetch<void>(`/users/${account.userId}/bookmarks`, {
|
return f<void>(`/users/${account.userId}/bookmarks`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: bookmarks,
|
body: bookmarks,
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
headers: getAuthHeaders(account.token),
|
headers: getAuthHeaders(account.token),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -1,21 +1,20 @@
|
|||||||
import { ofetch } from "ofetch";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ChallengeTokenResponse,
|
ChallengeTokenResponse,
|
||||||
LoginInput,
|
LoginInput,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { f } from "./fetch";
|
||||||
|
|
||||||
export async function getLoginChallengeToken(
|
export async function getLoginChallengeToken(
|
||||||
url: string,
|
url: string,
|
||||||
publicKey: string,
|
publicKey: string,
|
||||||
): Promise<ChallengeTokenResponse> {
|
): Promise<ChallengeTokenResponse> {
|
||||||
return ofetch<ChallengeTokenResponse>("/auth/login/start", {
|
return f<ChallengeTokenResponse>("/auth/login/start", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
publicKey,
|
publicKey,
|
||||||
},
|
},
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,12 +22,12 @@ export async function loginAccount(
|
|||||||
url: string,
|
url: string,
|
||||||
data: LoginInput,
|
data: LoginInput,
|
||||||
): Promise<LoginResponse> {
|
): Promise<LoginResponse> {
|
||||||
return ofetch<LoginResponse>("/auth/login/complete", {
|
return f<LoginResponse>("/auth/login/complete", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
namespace: "movie-web",
|
namespace: "movie-web",
|
||||||
...data,
|
...data,
|
||||||
},
|
},
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,8 @@
|
|||||||
import { ofetch } from "ofetch";
|
|
||||||
|
|
||||||
import type { MetaResponse } from "./types";
|
import type { MetaResponse } from "./types";
|
||||||
|
import { f } from "./fetch";
|
||||||
|
|
||||||
export async function getBackendMeta(url: string): Promise<MetaResponse> {
|
export function getBackendMeta(url: string): Promise<MetaResponse> {
|
||||||
return ofetch<MetaResponse>("/meta", {
|
return f<MetaResponse>("/meta", {
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,3 @@
|
|||||||
import { ofetch } from "ofetch";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AccountWithToken,
|
AccountWithToken,
|
||||||
ProgressInput,
|
ProgressInput,
|
||||||
@@ -8,6 +6,7 @@ import type {
|
|||||||
ProgressUpdateItem,
|
ProgressUpdateItem,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { getAuthHeaders } from "./auth";
|
import { getAuthHeaders } from "./auth";
|
||||||
|
import { f } from "./fetch";
|
||||||
|
|
||||||
export function progressUpdateItemToInput(
|
export function progressUpdateItemToInput(
|
||||||
item: ProgressUpdateItem,
|
item: ProgressUpdateItem,
|
||||||
@@ -72,12 +71,12 @@ export async function setProgress(
|
|||||||
account: AccountWithToken,
|
account: AccountWithToken,
|
||||||
input: ProgressInput,
|
input: ProgressInput,
|
||||||
) {
|
) {
|
||||||
return ofetch<ProgressResponse>(
|
return f<ProgressResponse>(
|
||||||
`/users/${account.userId}/progress/${input.tmdbId}`,
|
`/users/${account.userId}/progress/${input.tmdbId}`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: getAuthHeaders(account.token),
|
headers: getAuthHeaders(account.token),
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
body: input,
|
body: input,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -90,10 +89,10 @@ export async function removeProgress(
|
|||||||
episodeId?: string,
|
episodeId?: string,
|
||||||
seasonId?: string,
|
seasonId?: string,
|
||||||
) {
|
) {
|
||||||
await ofetch(`/users/${account.userId}/progress/${id}`, {
|
await f(`/users/${account.userId}/progress/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: getAuthHeaders(account.token),
|
headers: getAuthHeaders(account.token),
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
body: {
|
body: {
|
||||||
episodeId,
|
episodeId,
|
||||||
seasonId,
|
seasonId,
|
||||||
|
@@ -1,22 +1,21 @@
|
|||||||
import { ofetch } from "ofetch";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ChallengeTokenResponse,
|
ChallengeTokenResponse,
|
||||||
RegisterInput,
|
RegisterInput,
|
||||||
SessionResponse,
|
SessionResponse,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { f } from "./fetch";
|
||||||
|
|
||||||
export async function getRegisterChallengeToken(
|
export async function getRegisterChallengeToken(
|
||||||
url: string,
|
url: string,
|
||||||
captchaToken?: string,
|
captchaToken?: string,
|
||||||
): Promise<ChallengeTokenResponse> {
|
): Promise<ChallengeTokenResponse> {
|
||||||
return ofetch<ChallengeTokenResponse>("/auth/register/start", {
|
return f<ChallengeTokenResponse>("/auth/register/start", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
captchaToken,
|
captchaToken,
|
||||||
},
|
},
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,12 +29,12 @@ export async function registerAccount(
|
|||||||
url: string,
|
url: string,
|
||||||
data: RegisterInput,
|
data: RegisterInput,
|
||||||
): Promise<RegisterResponse> {
|
): Promise<RegisterResponse> {
|
||||||
return ofetch<RegisterResponse>("/auth/register/complete", {
|
return f<RegisterResponse>("/auth/register/complete", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
namespace: "movie-web",
|
namespace: "movie-web",
|
||||||
...data,
|
...data,
|
||||||
},
|
},
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -1,12 +1,11 @@
|
|||||||
import { ofetch } from "ofetch";
|
|
||||||
|
|
||||||
import type { AccountWithToken, SessionResponse, SessionUpdate } from "./types";
|
import type { AccountWithToken, SessionResponse, SessionUpdate } from "./types";
|
||||||
import { getAuthHeaders } from "./auth";
|
import { getAuthHeaders } from "./auth";
|
||||||
|
import { f } from "./fetch";
|
||||||
|
|
||||||
export async function getSessions(url: string, account: AccountWithToken) {
|
export async function getSessions(url: string, account: AccountWithToken) {
|
||||||
return ofetch<SessionResponse[]>(`/users/${account.userId}/sessions`, {
|
return f<SessionResponse[]>(`/users/${account.userId}/sessions`, {
|
||||||
headers: getAuthHeaders(account.token),
|
headers: getAuthHeaders(account.token),
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,11 +14,11 @@ export async function updateSession(
|
|||||||
account: AccountWithToken,
|
account: AccountWithToken,
|
||||||
update: SessionUpdate,
|
update: SessionUpdate,
|
||||||
) {
|
) {
|
||||||
return ofetch<SessionResponse[]>(`/sessions/${account.sessionId}`, {
|
return f<SessionResponse[]>(`/sessions/${account.sessionId}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: getAuthHeaders(account.token),
|
headers: getAuthHeaders(account.token),
|
||||||
body: update,
|
body: update,
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,9 +27,9 @@ export async function removeSession(
|
|||||||
token: string,
|
token: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
) {
|
) {
|
||||||
return ofetch<SessionResponse[]>(`/sessions/${sessionId}`, {
|
return f<SessionResponse[]>(`/sessions/${sessionId}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: getAuthHeaders(token),
|
headers: getAuthHeaders(token),
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -1,29 +1,28 @@
|
|||||||
import { ofetch } from "ofetch";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AccountWithToken,
|
AccountWithToken,
|
||||||
SettingsInput,
|
SettingsInput,
|
||||||
SettingsResponse,
|
SettingsResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { getAuthHeaders } from "./auth";
|
import { getAuthHeaders } from "./auth";
|
||||||
|
import { f } from "./fetch";
|
||||||
|
|
||||||
export function updateSettings(
|
export function updateSettings(
|
||||||
url: string,
|
url: string,
|
||||||
account: AccountWithToken,
|
account: AccountWithToken,
|
||||||
settings: SettingsInput,
|
settings: SettingsInput,
|
||||||
) {
|
) {
|
||||||
return ofetch<SettingsResponse>(`/users/${account.userId}/settings`, {
|
return f<SettingsResponse>(`/users/${account.userId}/settings`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: settings,
|
body: settings,
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
headers: getAuthHeaders(account.token),
|
headers: getAuthHeaders(account.token),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSettings(url: string, account: AccountWithToken) {
|
export function getSettings(url: string, account: AccountWithToken) {
|
||||||
return ofetch<SettingsResponse>(`/users/${account.userId}/settings`, {
|
return f<SettingsResponse>(`/users/${account.userId}/settings`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
headers: getAuthHeaders(account.token),
|
headers: getAuthHeaders(account.token),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,3 @@
|
|||||||
import { ofetch } from "ofetch";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AccountWithToken,
|
AccountWithToken,
|
||||||
BookmarkMediaItem,
|
BookmarkMediaItem,
|
||||||
@@ -11,6 +9,7 @@ import type {
|
|||||||
UserResponse,
|
UserResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { getAuthHeaders } from "./auth";
|
import { getAuthHeaders } from "./auth";
|
||||||
|
import { f } from "./fetch";
|
||||||
|
|
||||||
export function bookmarkResponsesToEntries(responses: BookmarkResponse[]) {
|
export function bookmarkResponsesToEntries(responses: BookmarkResponse[]) {
|
||||||
const entries = responses.map((bookmark) => {
|
const entries = responses.map((bookmark) => {
|
||||||
@@ -83,13 +82,10 @@ export async function getUser(
|
|||||||
url: string,
|
url: string,
|
||||||
token: string,
|
token: string,
|
||||||
): Promise<{ user: UserResponse; session: SessionResponse }> {
|
): Promise<{ user: UserResponse; session: SessionResponse }> {
|
||||||
return ofetch<{ user: UserResponse; session: SessionResponse }>(
|
return f<{ user: UserResponse; session: SessionResponse }>("/users/@me", {
|
||||||
"/users/@me",
|
|
||||||
{
|
|
||||||
headers: getAuthHeaders(token),
|
headers: getAuthHeaders(token),
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function editUser(
|
export async function editUser(
|
||||||
@@ -97,13 +93,13 @@ export async function editUser(
|
|||||||
account: AccountWithToken,
|
account: AccountWithToken,
|
||||||
object: UserEdit,
|
object: UserEdit,
|
||||||
): Promise<{ user: UserResponse; session: SessionResponse }> {
|
): Promise<{ user: UserResponse; session: SessionResponse }> {
|
||||||
return ofetch<{ user: UserResponse; session: SessionResponse }>(
|
return f<{ user: UserResponse; session: SessionResponse }>(
|
||||||
`/users/${account.userId}`,
|
`/users/${account.userId}`,
|
||||||
{
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: getAuthHeaders(account.token),
|
headers: getAuthHeaders(account.token),
|
||||||
body: object,
|
body: object,
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -112,22 +108,22 @@ export async function deleteUser(
|
|||||||
url: string,
|
url: string,
|
||||||
account: AccountWithToken,
|
account: AccountWithToken,
|
||||||
): Promise<UserResponse> {
|
): Promise<UserResponse> {
|
||||||
return ofetch<UserResponse>(`/users/${account.userId}`, {
|
return f<UserResponse>(`/users/${account.userId}`, {
|
||||||
headers: getAuthHeaders(account.token),
|
headers: getAuthHeaders(account.token),
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBookmarks(url: string, account: AccountWithToken) {
|
export async function getBookmarks(url: string, account: AccountWithToken) {
|
||||||
return ofetch<BookmarkResponse[]>(`/users/${account.userId}/bookmarks`, {
|
return f<BookmarkResponse[]>(`/users/${account.userId}/bookmarks`, {
|
||||||
headers: getAuthHeaders(account.token),
|
headers: getAuthHeaders(account.token),
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProgress(url: string, account: AccountWithToken) {
|
export async function getProgress(url: string, account: AccountWithToken) {
|
||||||
return ofetch<ProgressResponse[]>(`/users/${account.userId}/progress`, {
|
return f<ProgressResponse[]>(`/users/${account.userId}/progress`, {
|
||||||
headers: getAuthHeaders(account.token),
|
headers: getAuthHeaders(account.token),
|
||||||
baseURL: url,
|
baseUrl: url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
41
pnpm-lock.yaml
generated
41
pnpm-lock.yaml
generated
@@ -95,6 +95,9 @@ importers:
|
|||||||
expo-build-properties:
|
expo-build-properties:
|
||||||
specifier: ~0.11.1
|
specifier: ~0.11.1
|
||||||
version: 0.11.1(expo@50.0.14)
|
version: 0.11.1(expo@50.0.14)
|
||||||
|
expo-clipboard:
|
||||||
|
specifier: ^5.0.1
|
||||||
|
version: 5.0.1(expo@50.0.14)
|
||||||
expo-constants:
|
expo-constants:
|
||||||
specifier: ~15.4.5
|
specifier: ~15.4.5
|
||||||
version: 15.4.5(expo@50.0.14)
|
version: 15.4.5(expo@50.0.14)
|
||||||
@@ -197,6 +200,9 @@ importers:
|
|||||||
tamagui:
|
tamagui:
|
||||||
specifier: ^1.94.0
|
specifier: ^1.94.0
|
||||||
version: 1.94.0(@types/react@18.2.52)(immer@10.0.3)(react-dom@18.2.0)(react-native-web@0.19.10)(react-native@0.73.6)(react@18.2.0)
|
version: 1.94.0(@types/react@18.2.52)(immer@10.0.3)(react-dom@18.2.0)(react-native-web@0.19.10)(react-native@0.73.6)(react@18.2.0)
|
||||||
|
text-encoding-polyfill:
|
||||||
|
specifier: ^0.6.7
|
||||||
|
version: 0.6.7
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^4.4.7
|
specifier: ^4.4.7
|
||||||
version: 4.4.7(@types/react@18.2.52)(immer@10.0.3)(react@18.2.0)
|
version: 4.4.7(@types/react@18.2.52)(immer@10.0.3)(react@18.2.0)
|
||||||
@@ -252,9 +258,6 @@ importers:
|
|||||||
node-forge:
|
node-forge:
|
||||||
specifier: ^1.3.1
|
specifier: ^1.3.1
|
||||||
version: 1.3.1
|
version: 1.3.1
|
||||||
ofetch:
|
|
||||||
specifier: ^1.3.4
|
|
||||||
version: 1.3.4
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@movie-web/eslint-config':
|
'@movie-web/eslint-config':
|
||||||
specifier: workspace:^0.2.0
|
specifier: workspace:^0.2.0
|
||||||
@@ -7115,10 +7118,6 @@ packages:
|
|||||||
minimalistic-assert: 1.0.1
|
minimalistic-assert: 1.0.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/destr@2.0.3:
|
|
||||||
resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/destroy@1.2.0:
|
/destroy@1.2.0:
|
||||||
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
|
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
|
||||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||||
@@ -7864,6 +7863,14 @@ packages:
|
|||||||
semver: 7.5.4
|
semver: 7.5.4
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/expo-clipboard@5.0.1(expo@50.0.14):
|
||||||
|
resolution: {integrity: sha512-JH853QJPr5W3h87If3aDTnMK+ESSIrwzU2TdfZrqZttVDY2pMIf/w37mVHHNYodXM4ATHXadtOkjKbAa0DWwUg==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
dependencies:
|
||||||
|
expo: 50.0.14(@babel/core@7.23.9)(@react-native/babel-preset@0.73.21)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/expo-constants@15.4.5(expo@50.0.14):
|
/expo-constants@15.4.5(expo@50.0.14):
|
||||||
resolution: {integrity: sha512-1pVVjwk733hbbIjtQcvUFCme540v4gFemdNlaxM2UXKbfRCOh2hzgKN5joHMOysoXQe736TTUrRj7UaZI5Yyhg==}
|
resolution: {integrity: sha512-1pVVjwk733hbbIjtQcvUFCme540v4gFemdNlaxM2UXKbfRCOh2hzgKN5joHMOysoXQe736TTUrRj7UaZI5Yyhg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -10545,10 +10552,6 @@ packages:
|
|||||||
engines: {node: '>=10.5.0'}
|
engines: {node: '>=10.5.0'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/node-fetch-native@1.6.4:
|
|
||||||
resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/node-fetch@2.7.0:
|
/node-fetch@2.7.0:
|
||||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||||
engines: {node: 4.x || >=6.0.0}
|
engines: {node: 4.x || >=6.0.0}
|
||||||
@@ -10727,14 +10730,6 @@ packages:
|
|||||||
es-abstract: 1.22.3
|
es-abstract: 1.22.3
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/ofetch@1.3.4:
|
|
||||||
resolution: {integrity: sha512-KLIET85ik3vhEfS+3fDlc/BAZiAp+43QEC/yCo5zkNoY2YaKvNkOaFr/6wCFgFH1kuYQM5pMNi0Tg8koiIemtw==}
|
|
||||||
dependencies:
|
|
||||||
destr: 2.0.3
|
|
||||||
node-fetch-native: 1.6.4
|
|
||||||
ufo: 1.5.3
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/on-finished@2.3.0:
|
/on-finished@2.3.0:
|
||||||
resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
|
resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -13083,6 +13078,10 @@ packages:
|
|||||||
source-map-support: 0.5.21
|
source-map-support: 0.5.21
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/text-encoding-polyfill@0.6.7:
|
||||||
|
resolution: {integrity: sha512-/DZ1XJqhbqRkCop6s9ZFu8JrFRwmVuHg4quIRm+ziFkR3N3ec6ck6yBvJ1GYeEQZhLVwRW0rZE+C3SSJpy0RTg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/text-table@0.2.0:
|
/text-table@0.2.0:
|
||||||
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
||||||
|
|
||||||
@@ -13382,10 +13381,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
|
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/ufo@1.5.3:
|
|
||||||
resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/uglify-js@3.17.4:
|
/uglify-js@3.17.4:
|
||||||
resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==}
|
resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==}
|
||||||
engines: {node: '>=0.8.0'}
|
engines: {node: '>=0.8.0'}
|
||||||
|
Reference in New Issue
Block a user